@lh8ppl/claude-memory-kit 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 (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,345 @@
1
+ // review-queue.mjs — Task 26 (T-023). Public boundary for the
2
+ // medium-trust review queue resolver.
3
+ //
4
+ // Per design §6.2:
5
+ // - Auto-extract routes high-trust → MEMORY.md (direct)
6
+ // - Auto-extract routes medium-trust → queues/review.md (this module)
7
+ // - Auto-extract routes low-trust → discarded (logged to extract.log)
8
+ //
9
+ // The medium-trust ROUTING is already done by `routeMedium` in
10
+ // `auto-extract.mjs` (Task 23). This module handles the RESOLVER side:
11
+ // the `cmk queue review` interactive walker that processes pending
12
+ // entries one-at-a-time and accepts `promote` / `discard` / `skip`.
13
+ //
14
+ // Companion to the conflict-queue (Task 25; design §6.8). Same shape
15
+ // (interactive walker + per-entry decisions) but different routing:
16
+ // - Review queue: new medium-trust candidates awaiting blessing
17
+ // - Conflict queue: new writes that contradict existing higher-
18
+ // trust facts
19
+ //
20
+ // Idempotency-convention asymmetry (code-review note): review-queue
21
+ // REMOVES resolved entries from review.md; conflict-queue PRESERVES
22
+ // them with `resolution: <decision>` markers. The asymmetry is
23
+ // intentional — review is transient (once promoted, the candidate's
24
+ // outcome is in MEMORY.md; once discarded, in audit.log), so the
25
+ // queue file shouldn't grow unbounded with resolved entries.
26
+ // Conflict-queue is preservational (file-as-history serves audit).
27
+ //
28
+ // Parser tolerance (code-review note): parseReviewQueue silently
29
+ // skips malformed blocks. Matches kit pattern (lock-discipline,
30
+ // conflict-queue). User manually editing review.md with bad
31
+ // markdown loses the malformed block but doesn't crash the resolver.
32
+ // Trade-off: data-loss risk on bad edits vs. robustness against
33
+ // editor noise. The kit's overall posture is "be tolerant of file
34
+ // edits; never crash the kit on malformed memory state".
35
+ //
36
+ // Public surface:
37
+ // - parseReviewQueue(text) — parses the review.md format into
38
+ // entries (`## <ts> — auto-extract (medium-trust, pending review)`
39
+ // heading + bullet + provenance + blank line)
40
+ // - resolveReviewQueue({tier, projectRoot, userDir, prompter}) —
41
+ // walks pending entries one-at-a-time; on each, the prompter
42
+ // returns 'promote' / 'discard' / 'skip'. Resolved entries are
43
+ // REMOVED from review.md (per task 26.3/26.4). Skipped entries
44
+ // stay. Audit-log entries written for promote + discard.
45
+ //
46
+ // File format (matches routeMedium's output in auto-extract.mjs):
47
+ //
48
+ // ## 2026-05-27T10:00:00Z — auto-extract (medium-trust, pending review)
49
+ // - (P-AAAAAAAA) the candidate text
50
+ // <!-- proposed_trust: medium, write: auto-extract, at: 2026-05-27T10:00:00Z -->
51
+ //
52
+ // ## 2026-05-27T10:01:00Z — auto-extract (medium-trust, pending review)
53
+ // - (P-BBBBBBBB) another candidate
54
+ // <!-- proposed_trust: medium, write: auto-extract, at: 2026-05-27T10:01:00Z -->
55
+ //
56
+ // Uses shared modules per CLAUDE.md "Shared modules" rule.
57
+
58
+ import {
59
+ existsSync,
60
+ readFileSync,
61
+ writeFileSync,
62
+ } from 'node:fs';
63
+ import { join } from 'node:path';
64
+ import { resolveTierRoot, VALID_TIERS } from './tier-paths.mjs';
65
+ import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
66
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
67
+ import { memoryWrite } from './memory-write.mjs';
68
+
69
+ const QUEUE_RELATIVE = ['queues', 'review.md'];
70
+
71
+ // --- Parsing -------------------------------------------------------
72
+
73
+ const HEADING_RE = /^##\s+(.+?)\s+—\s+auto-extract\s+\(medium-trust,\s+pending review\)\s*$/;
74
+ const BULLET_LINE_RE = /^- \(([PUL]-[A-Za-z0-9]{8})\)\s+(.+)$/;
75
+ const PROVENANCE_RE = /^\s+<!--\s*(.+?)\s*-->\s*$/;
76
+
77
+ /**
78
+ * Parse the review.md file body into an entry array.
79
+ *
80
+ * Returns: { entries: [{ ts, id, text, provenance, startLine, endLine }],
81
+ * preamble: string[] }
82
+ *
83
+ * Entries are ORDERED — earliest at index 0. preamble holds any text
84
+ * before the first heading (typically empty or comments). The parser
85
+ * is tolerant: any block that doesn't match the heading+bullet+
86
+ * provenance triple is skipped silently (the resolver doesn't lose
87
+ * data — it just doesn't display malformed entries).
88
+ */
89
+ export function parseReviewQueue(text) {
90
+ const lines = String(text ?? '').split(/\r?\n/);
91
+ const entries = [];
92
+ let preambleEnd = 0;
93
+ let i = 0;
94
+
95
+ // Skip any preamble before the first heading.
96
+ while (i < lines.length && !HEADING_RE.test(lines[i])) {
97
+ i++;
98
+ }
99
+ preambleEnd = i;
100
+
101
+ while (i < lines.length) {
102
+ const headingMatch = lines[i].match(HEADING_RE);
103
+ if (!headingMatch) {
104
+ i++;
105
+ continue;
106
+ }
107
+ const ts = headingMatch[1];
108
+ const startLine = i;
109
+ // Next non-blank line should be the bullet.
110
+ let j = i + 1;
111
+ let bulletLine = -1;
112
+ let provenanceLine = -1;
113
+ while (j < lines.length && j < i + 5) {
114
+ if (BULLET_LINE_RE.test(lines[j])) {
115
+ bulletLine = j;
116
+ break;
117
+ }
118
+ j++;
119
+ }
120
+ if (bulletLine === -1) {
121
+ i++;
122
+ continue;
123
+ }
124
+ const bulletMatch = lines[bulletLine].match(BULLET_LINE_RE);
125
+ const id = bulletMatch[1];
126
+ const bulletText = bulletMatch[2];
127
+ // Provenance comment is the next line.
128
+ if (bulletLine + 1 < lines.length && PROVENANCE_RE.test(lines[bulletLine + 1])) {
129
+ provenanceLine = bulletLine + 1;
130
+ }
131
+ // entry ends at the next heading or EOF.
132
+ let endLine = (provenanceLine === -1 ? bulletLine : provenanceLine) + 1;
133
+ while (endLine < lines.length && !HEADING_RE.test(lines[endLine])) {
134
+ // Stop at blank-line boundary if the next non-blank starts a heading.
135
+ if (lines[endLine].trim() === '') {
136
+ endLine++;
137
+ continue;
138
+ }
139
+ // Non-empty, non-heading line — could be a malformed entry.
140
+ // Conservative: include it in this entry.
141
+ endLine++;
142
+ }
143
+ entries.push({
144
+ ts,
145
+ id,
146
+ text: bulletText,
147
+ provenance: provenanceLine !== -1 ? lines[provenanceLine] : null,
148
+ startLine,
149
+ endLine, // exclusive
150
+ });
151
+ i = endLine;
152
+ }
153
+
154
+ return {
155
+ entries,
156
+ preamble: lines.slice(0, preambleEnd),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Re-serialize entries back to the review.md file format. Used by
162
+ * the resolver after promote/discard removals to keep the file
163
+ * coherent.
164
+ */
165
+ function serializeReviewQueue({ preamble, entries }) {
166
+ const lines = [...preamble];
167
+ for (const e of entries) {
168
+ lines.push(`## ${e.ts} — auto-extract (medium-trust, pending review)`);
169
+ lines.push(`- (${e.id}) ${e.text}`);
170
+ if (e.provenance) lines.push(e.provenance);
171
+ lines.push('');
172
+ }
173
+ return lines.join('\n');
174
+ }
175
+
176
+ // --- Public: resolveReviewQueue ------------------------------------
177
+
178
+ /**
179
+ * Walk pending review.md entries one-at-a-time. The `prompter` is a
180
+ * caller-supplied function: `async ({id, text, ts, provenance}) →
181
+ * 'promote' | 'discard' | 'skip'`. Lets the CLI / tests inject
182
+ * behavior; production wires through the interactive `cmk queue
183
+ * review` verb.
184
+ *
185
+ * Decisions:
186
+ * - 'promote' → invokes memoryWrite({action: 'add', trust: 'high',
187
+ * source: 'review-promote', text, tier, ...}). Removes entry
188
+ * from review.md. Audit-log entry written by memoryWrite (action:
189
+ * 'promoted'/scratchpad-append) AND this module (action: 'promoted'
190
+ * for review-queue tracking).
191
+ * - 'discard' → removes entry from review.md. Audit-log entry:
192
+ * action: 'discarded', reasonCode: REVIEW_DISCARDED.
193
+ * - 'skip' → entry stays in review.md. No audit-log entry.
194
+ * - anything else → treated as 'skip' (defensive).
195
+ *
196
+ * Returns: { promoted: N, discarded: N, skipped: N, errors: [] } on
197
+ * success, OR errorResult on schema failure.
198
+ *
199
+ * Note on the memoryWrite call: this passes `scratchpad: 'MEMORY.md'`
200
+ * + `section: 'Active Threads'` as v0.1 defaults. The kit's
201
+ * memory-write skill picks the section based on heuristics in v0.1.x.
202
+ */
203
+ // Default `section` is 'Active Threads' — the catchall section in
204
+ // the kit's seed MEMORY.md scaffold (per design §2.1). v0.1.x
205
+ // candidate: per-candidate section routing via heuristics in the
206
+ // memory-write skill (requires `routeMedium` in auto-extract.mjs
207
+ // to capture the target section at write-time, currently it doesn't
208
+ // — review.md format has no section field). For v0.1.0 the catchall
209
+ // works because Active Threads is where transient observations
210
+ // belong by convention; the user can promote into a different
211
+ // section by editing MEMORY.md after promotion.
212
+ export async function resolveReviewQueue({
213
+ tier,
214
+ projectRoot,
215
+ userDir,
216
+ prompter,
217
+ scratchpad = 'MEMORY.md',
218
+ section = 'Active Threads',
219
+ } = {}) {
220
+ if (!VALID_TIERS.has(tier)) {
221
+ return errorResult({
222
+ category: ERROR_CATEGORIES.SCHEMA,
223
+ errors: [`resolveReviewQueue: tier must be one of P/U/L (got ${tier})`],
224
+ });
225
+ }
226
+ if (typeof prompter !== 'function') {
227
+ return errorResult({
228
+ category: ERROR_CATEGORIES.SCHEMA,
229
+ errors: ['resolveReviewQueue: prompter required (function)'],
230
+ });
231
+ }
232
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
233
+ const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
234
+ if (!existsSync(queuePath)) {
235
+ return { promoted: 0, discarded: 0, skipped: 0, errors: [] };
236
+ }
237
+
238
+ const text = readFileSync(queuePath, 'utf8');
239
+ const { entries, preamble } = parseReviewQueue(text);
240
+ if (entries.length === 0) {
241
+ return { promoted: 0, discarded: 0, skipped: 0, errors: [] };
242
+ }
243
+
244
+ let promoted = 0;
245
+ let discarded = 0;
246
+ let skipped = 0;
247
+ const errors = [];
248
+ const keep = []; // entries that remain after this pass
249
+
250
+ for (const entry of entries) {
251
+ const decision = await prompter({
252
+ id: entry.id,
253
+ text: entry.text,
254
+ ts: entry.ts,
255
+ provenance: entry.provenance,
256
+ });
257
+
258
+ if (decision === 'promote') {
259
+ // Promote via memoryWrite — adds at trust: high. The proposed id
260
+ // from review.md is recomputed by memoryWrite from the canonical
261
+ // text, so it'll match if the text hasn't been edited.
262
+ //
263
+ // Composition note (code-review IMP-1): memoryWrite runs
264
+ // detectConflicts on every add (Task 25 integration). If the
265
+ // promoted text conflicts with an existing high-trust bullet,
266
+ // the result will be `{action: 'queued', ...}` — re-routed to
267
+ // queues/conflicts.md. This is by design (high-trust + similar-
268
+ // to-existing IS a conflict that needs surfacing), not a bug.
269
+ // The resolver reports this explicitly so the user understands
270
+ // where the promoted entry ended up.
271
+ const result = memoryWrite({
272
+ action: 'add',
273
+ tier,
274
+ scratchpad,
275
+ section,
276
+ text: entry.text,
277
+ source: 'review-promote',
278
+ trust: 'high',
279
+ projectRoot,
280
+ userDir,
281
+ now: nowIso(),
282
+ });
283
+ if (result.action === 'error') {
284
+ errors.push({
285
+ id: entry.id,
286
+ decision,
287
+ errors: result.errors,
288
+ });
289
+ keep.push(entry);
290
+ continue;
291
+ }
292
+ // Detect the re-route case: memoryWrite returned `action: 'queued'`
293
+ // (conflict-queue route), not `action: 'created'` / 'appended'.
294
+ const rerouted = result.action === 'queued';
295
+ appendAuditEntry(tierRoot, {
296
+ ts: nowIso(),
297
+ action: 'promoted',
298
+ tier,
299
+ id: entry.id,
300
+ reasonCode: REASON_CODES.REVIEW_PROMOTED,
301
+ reasonText: rerouted
302
+ ? `review-queue: promoted ${entry.id} but re-routed to conflict-queue (collision with ${result.conflictsWith}; resolve via cmk queue conflicts)`
303
+ : `review-queue: promoted ${entry.id} to ${scratchpad} (trust: high)`,
304
+ extra: {
305
+ decision,
306
+ from_queue: 'review',
307
+ original_ts: entry.ts,
308
+ ...(rerouted
309
+ ? {
310
+ rerouted_to: 'conflicts',
311
+ rerouted_as: result.id,
312
+ conflicts_with: result.conflictsWith,
313
+ }
314
+ : {}),
315
+ },
316
+ });
317
+ promoted++;
318
+ } else if (decision === 'discard') {
319
+ appendAuditEntry(tierRoot, {
320
+ ts: nowIso(),
321
+ action: 'discarded',
322
+ tier,
323
+ id: entry.id,
324
+ reasonCode: REASON_CODES.REVIEW_DISCARDED,
325
+ reasonText: `review-queue: discarded ${entry.id} (medium-trust auto-extract candidate)`,
326
+ extra: {
327
+ decision,
328
+ from_queue: 'review',
329
+ original_ts: entry.ts,
330
+ },
331
+ });
332
+ discarded++;
333
+ } else {
334
+ // skip OR unknown → preserve entry as-is, no audit.
335
+ skipped++;
336
+ keep.push(entry);
337
+ }
338
+ }
339
+
340
+ // Rewrite the queue with only the kept (skipped + errored) entries.
341
+ const newBody = serializeReviewQueue({ preamble, entries: keep });
342
+ writeFileSync(queuePath, newBody, 'utf8');
343
+
344
+ return { promoted, discarded, skipped, errors };
345
+ }
package/src/roll.mjs ADDED
@@ -0,0 +1,115 @@
1
+ // `cmk roll` (Task 39, T-033, parts 39.4–39.5).
2
+ //
3
+ // Public boundary:
4
+ // async runRoll({projectRoot, scope: 'now'|'today'|'recent', backend, now})
5
+ // → {action, scope, delegatedTo, result, duration_ms}
6
+ //
7
+ // Manually trigger one of the kit's compression pipelines on demand:
8
+ // - 'now' : task 22 compress-session — compresses now.md to today-{date}.md (DEFAULT)
9
+ // - 'today' : task 33 daily-distill — rolls today-*.md into recent.md
10
+ // - 'recent' : task 34 weekly-curate — archives today-*.md >7d into archive.md
11
+ //
12
+ // All three pipelines already implement the Haiku call + cooldown +
13
+ // audit-log discipline. This dispatcher passes the backend through to
14
+ // the appropriate one. `cooldownMs: 0` override because `cmk roll`
15
+ // is an explicit user-invoked operation — the user opted in, and the
16
+ // shared 120s cooldown shouldn't gate a manual command.
17
+ //
18
+ // Per design §8 + tasks.md 39.4–39.5.
19
+
20
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
21
+ import { nowIso } from './audit-log.mjs';
22
+
23
+ export const ROLL_SCOPES = Object.freeze({
24
+ NOW: 'now',
25
+ TODAY: 'today',
26
+ RECENT: 'recent',
27
+ });
28
+
29
+ /**
30
+ * Public boundary: run the roll pipeline.
31
+ *
32
+ * @param {object} opts
33
+ * @param {string} opts.projectRoot
34
+ * @param {string} [opts.scope] 'now' (default) | 'today' | 'recent'
35
+ * @param {object} opts.backend CompressorBackend (HaikuViaAnthropicApi or MockHaikuBackend)
36
+ * @param {string} [opts.now]
37
+ * @returns {Promise<object>}
38
+ *
39
+ * Note: I2 fix (skill-review 2026-05-28) dropped the v0.1.0 `userDir`
40
+ * forward-compat parameter. The underlying pipelines (compress-session,
41
+ * daily-distill, weekly-curate) all operate purely on projectRoot — none
42
+ * take userDir. When user-tier roll operations land (v0.1.x), the param
43
+ * lands at that PR alongside the actual consumer.
44
+ */
45
+ export async function runRoll({
46
+ projectRoot,
47
+ scope = ROLL_SCOPES.NOW,
48
+ backend,
49
+ now,
50
+ } = {}) {
51
+ const ts = now ?? nowIso();
52
+ const t0 = Date.now();
53
+ if (!projectRoot) {
54
+ return errorResult({
55
+ category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
56
+ errors: ['projectRoot is required'],
57
+ duration_ms: Date.now() - t0,
58
+ });
59
+ }
60
+ if (!backend || typeof backend.compress !== 'function') {
61
+ return errorResult({
62
+ category: ERROR_CATEGORIES.MISSING_BACKEND,
63
+ errors: ['backend (CompressorBackend) is required'],
64
+ duration_ms: Date.now() - t0,
65
+ });
66
+ }
67
+ if (!Object.values(ROLL_SCOPES).includes(scope)) {
68
+ return errorResult({
69
+ category: ERROR_CATEGORIES.SCHEMA,
70
+ errors: [`invalid scope: ${scope}; expected 'now' | 'today' | 'recent'`],
71
+ duration_ms: Date.now() - t0,
72
+ });
73
+ }
74
+
75
+ // Lazy-load the underlying pipeline modules so this module's
76
+ // public surface stays narrow + import-cost stays low.
77
+ let result;
78
+ let delegatedTo;
79
+ if (scope === ROLL_SCOPES.NOW) {
80
+ const { compressSession } = await import('./compress-session.mjs');
81
+ delegatedTo = 'compress-session';
82
+ result = await compressSession({
83
+ projectRoot,
84
+ backend,
85
+ now: ts,
86
+ cooldownMs: 0, // user-explicit invocation bypasses 120s gate
87
+ });
88
+ } else if (scope === ROLL_SCOPES.TODAY) {
89
+ const { dailyDistill } = await import('./daily-distill.mjs');
90
+ delegatedTo = 'daily-distill';
91
+ result = await dailyDistill({
92
+ projectRoot,
93
+ backend,
94
+ now: ts,
95
+ cooldownMs: 0,
96
+ });
97
+ } else {
98
+ const { weeklyCurate } = await import('./weekly-curate.mjs');
99
+ delegatedTo = 'weekly-curate';
100
+ result = await weeklyCurate({
101
+ projectRoot,
102
+ backend,
103
+ now: ts,
104
+ cooldownMs: 0,
105
+ });
106
+ }
107
+
108
+ return {
109
+ action: 'completed',
110
+ scope,
111
+ delegatedTo,
112
+ result,
113
+ duration_ms: Date.now() - t0,
114
+ };
115
+ }