discoclaw 1.0.0 → 1.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.
package/.context/dev.md CHANGED
@@ -171,18 +171,18 @@ Two setup paths:
171
171
  ### Plan & Forge
172
172
  | Variable | Default | Description |
173
173
  |----------|---------|-------------|
174
- | `DISCOCLAW_PLAN_COMMANDS_ENABLED` | `1` | Enable plan commands (`!plan`, `!plan phase`, etc.) |
175
- | `PLAN_PHASES_ENABLED` | `1` | Enable phase-by-phase plan execution |
174
+ | `DISCOCLAW_PLAN_COMMANDS_ENABLED` | `0` | Enable plan commands (`!plan`, `!plan phase`, etc.) |
175
+ | `PLAN_PHASES_ENABLED` | `0` | Enable phase-by-phase plan execution |
176
176
  | `PLAN_PHASE_MAX_CONTEXT_FILES` | `5` | Max `.context/` files injected per plan phase |
177
177
  | `PLAN_PHASE_TIMEOUT_MS` | `1800000` | Per-phase timeout in milliseconds |
178
178
  | `PLAN_PHASE_AUDIT_FIX_MAX` | `3` | Max audit-fix attempts per phase before giving up |
179
- | `DISCOCLAW_FORGE_COMMANDS_ENABLED` | `1` | Enable forge commands (`!forge`) |
179
+ | `DISCOCLAW_FORGE_COMMANDS_ENABLED` | `0` | Enable forge commands (`!forge`) |
180
180
  | `FORGE_MAX_AUDIT_ROUNDS` | `5` | Max audit rounds before forge accepts the draft |
181
181
  | `FORGE_DRAFTER_MODEL` | *(empty — uses `RUNTIME_MODEL`)* | Override model for the forge drafter step |
182
182
  | `FORGE_AUDITOR_MODEL` | *(empty — uses `RUNTIME_MODEL`)* | Override model for the forge auditor step |
183
183
  | `FORGE_TIMEOUT_MS` | `1800000` | Per-forge-session timeout in milliseconds |
184
184
  | `FORGE_PROGRESS_THROTTLE_MS` | `3000` | Min ms between forge progress Discord updates |
185
- | `FORGE_AUTO_IMPLEMENT` | `1` | Automatically implement the approved forge plan without a separate confirm step |
185
+ | `FORGE_AUTO_IMPLEMENT` | `0` | Automatically implement the approved forge plan without a separate confirm step |
186
186
  | `FORGE_DRAFTER_RUNTIME` | *(empty — uses `PRIMARY_RUNTIME`)* | Runtime adapter for the forge drafter (e.g. `openai`, `claude`) |
187
187
  | `FORGE_AUDITOR_RUNTIME` | *(empty — uses `PRIMARY_RUNTIME`)* | Runtime adapter for the forge auditor |
188
188
 
package/.env.example.full CHANGED
@@ -217,11 +217,11 @@ DISCORD_GUILD_ID=
217
217
  #DISCOCLAW_DISCORD_ACTIONS_POLLS=1
218
218
  #DISCOCLAW_DISCORD_ACTIONS_TASKS=1
219
219
  # Allow the AI to self-initiate forge runs (draft + audit loops) via action blocks.
220
- # Requires DISCOCLAW_FORGE_COMMANDS_ENABLED=1. Only one forge at a time. Default: 1.
221
- #DISCOCLAW_DISCORD_ACTIONS_FORGE=1
220
+ # Requires DISCOCLAW_FORGE_COMMANDS_ENABLED=1. Only one forge at a time. Default: 0.
221
+ #DISCOCLAW_DISCORD_ACTIONS_FORGE=0
222
222
  # Allow the AI to create, inspect, approve, run, and close plans via action blocks.
223
- # Requires DISCOCLAW_PLAN_COMMANDS_ENABLED=1. Default: 1.
224
- #DISCOCLAW_DISCORD_ACTIONS_PLAN=1
223
+ # Requires DISCOCLAW_PLAN_COMMANDS_ENABLED=1. Default: 0.
224
+ #DISCOCLAW_DISCORD_ACTIONS_PLAN=0
225
225
  # Allow the AI to read/write durable memory (facts, preferences) via action blocks.
226
226
  # Requires DISCOCLAW_DURABLE_MEMORY_ENABLED=1. Default: 1.
227
227
  #DISCOCLAW_DISCORD_ACTIONS_MEMORY=1
@@ -541,9 +541,10 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
541
541
  # ----------------------------------------------------------
542
542
  # Plan & Forge — AI-assisted planning and implementation
543
543
  # ----------------------------------------------------------
544
- # Enable/disable !plan and !forge commands.
545
- #DISCOCLAW_PLAN_COMMANDS_ENABLED=1
546
- #DISCOCLAW_FORGE_COMMANDS_ENABLED=1
544
+ # Enable/disable !plan and !forge commands. Default: 0 (off).
545
+ # When off, !plan and !forge respond with a friendly nudge explaining how to enable.
546
+ #DISCOCLAW_PLAN_COMMANDS_ENABLED=0
547
+ #DISCOCLAW_FORGE_COMMANDS_ENABLED=0
547
548
  # Phase decomposition for approved plans.
548
549
  #PLAN_PHASES_ENABLED=1
549
550
  # Max files per phase batch during decomposition.
package/dist/config.js CHANGED
@@ -384,8 +384,8 @@ export function parseConfig(env) {
384
384
  const discordActionsTasks = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_TASKS', true);
385
385
  const discordActionsCrons = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_CRONS', true);
386
386
  const discordActionsBotProfile = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_BOT_PROFILE', true);
387
- const discordActionsForge = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_FORGE', true);
388
- const discordActionsPlan = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_PLAN', true);
387
+ const discordActionsForge = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_FORGE', false);
388
+ const discordActionsPlan = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_PLAN', false);
389
389
  const discordActionsMemory = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_MEMORY', true);
390
390
  const discordActionsDefer = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER', true);
391
391
  const discordActionsLoop = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_LOOP', true);
@@ -694,6 +694,8 @@ export function parseConfig(env) {
694
694
  loopMaxIntervalSeconds,
695
695
  loopMaxConcurrent,
696
696
  messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
697
+ messageHistoryFetchLimit: parsePositiveInt(env, 'DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT', 10),
698
+ messageHistoryMaxAgeMs: parsePositiveInt(env, 'DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS', 48) * 3_600_000,
697
699
  summaryEnabled: parseBoolean(env, 'DISCOCLAW_SUMMARY_ENABLED', true),
698
700
  summaryModel: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_MODEL') ?? fastModel,
699
701
  summaryMaxChars: parseNonNegativeInt(env, 'DISCOCLAW_SUMMARY_MAX_CHARS', 2000),
@@ -710,19 +712,19 @@ export function parseConfig(env) {
710
712
  memoryConsolidationThreshold: parsePositiveInt(env, 'DISCOCLAW_MEMORY_CONSOLIDATION_THRESHOLD', 50),
711
713
  memoryConsolidationModel: parseTrimmedString(env, 'DISCOCLAW_MEMORY_CONSOLIDATION_MODEL') ?? fastModel,
712
714
  memoryCommandsEnabled: parseBoolean(env, 'DISCOCLAW_MEMORY_COMMANDS_ENABLED', true),
713
- planCommandsEnabled: parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED', true),
714
- planPhasesEnabled: parseBoolean(env, 'PLAN_PHASES_ENABLED', true),
715
+ planCommandsEnabled: parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED', false),
716
+ planPhasesEnabled: parseBoolean(env, 'PLAN_PHASES_ENABLED', parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED', false)),
715
717
  planPhaseMaxContextFiles: parsePositiveInt(env, 'PLAN_PHASE_MAX_CONTEXT_FILES', 5),
716
718
  planPhaseTimeoutMs: parsePositiveNumber(env, 'PLAN_PHASE_TIMEOUT_MS', DEFAULT_THIRTY_MINUTES_MS),
717
719
  planPhaseMaxAuditFixAttempts: parseNonNegativeInt(env, 'PLAN_PHASE_AUDIT_FIX_MAX', 3),
718
720
  planForgeHeartbeatIntervalMs: parseNonNegativeInt(env, 'PLAN_FORGE_HEARTBEAT_INTERVAL_MS', DEFAULT_PLAN_FORGE_HEARTBEAT_INTERVAL_MS),
719
- forgeCommandsEnabled: parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED', true),
721
+ forgeCommandsEnabled: parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED', false),
720
722
  forgeMaxAuditRounds: parsePositiveInt(env, 'FORGE_MAX_AUDIT_ROUNDS', 5),
721
723
  forgeDrafterModel: parseTrimmedString(env, 'FORGE_DRAFTER_MODEL'),
722
724
  forgeAuditorModel: parseTrimmedString(env, 'FORGE_AUDITOR_MODEL'),
723
725
  forgeTimeoutMs: parsePositiveNumber(env, 'FORGE_TIMEOUT_MS', DEFAULT_THIRTY_MINUTES_MS),
724
726
  forgeProgressThrottleMs: parseNonNegativeInt(env, 'FORGE_PROGRESS_THROTTLE_MS', 3000),
725
- forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', true),
727
+ forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED', false)),
726
728
  completionNotifyEnabled: parseBoolean(env, 'DISCOCLAW_COMPLETION_NOTIFY', true),
727
729
  completionNotifyThresholdMs: parseNonNegativeInt(env, 'DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS', 30000),
728
730
  actionFollowupTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS', 30000),
@@ -275,9 +275,9 @@ describe('parseConfig', () => {
275
275
  const { config } = parseConfig(env());
276
276
  expect(config.discordActionsBotProfile).toBe(true);
277
277
  });
278
- it('defaults discordActionsPlan to true', () => {
278
+ it('defaults discordActionsPlan to false', () => {
279
279
  const { config } = parseConfig(env());
280
- expect(config.discordActionsPlan).toBe(true);
280
+ expect(config.discordActionsPlan).toBe(false);
281
281
  });
282
282
  it('defaults discordActionsEnabled to true', () => {
283
283
  const { config } = parseConfig(env());
@@ -503,9 +503,9 @@ describe('parseConfig', () => {
503
503
  expect(warnings.some((w) => w.includes('FORGE_AUDITOR_RUNTIME=openrouter'))).toBe(true);
504
504
  });
505
505
  // --- Forge auto-implement ---
506
- it('defaults forgeAutoImplement to true', () => {
506
+ it('defaults forgeAutoImplement to false', () => {
507
507
  const { config } = parseConfig(env());
508
- expect(config.forgeAutoImplement).toBe(true);
508
+ expect(config.forgeAutoImplement).toBe(false);
509
509
  });
510
510
  it('parses FORGE_AUTO_IMPLEMENT=0 as false', () => {
511
511
  const { config } = parseConfig(env({ FORGE_AUTO_IMPLEMENT: '0' }));
@@ -1192,4 +1192,20 @@ describe('parseConfig', () => {
1192
1192
  const { config } = parseConfig(env());
1193
1193
  expect(config.coldStorageChannelFilter).toEqual([]);
1194
1194
  });
1195
+ it('defaults messageHistoryFetchLimit to 10', () => {
1196
+ const { config } = parseConfig(env());
1197
+ expect(config.messageHistoryFetchLimit).toBe(10);
1198
+ });
1199
+ it('parses DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT when set', () => {
1200
+ const { config } = parseConfig(env({ DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT: '25' }));
1201
+ expect(config.messageHistoryFetchLimit).toBe(25);
1202
+ });
1203
+ it('defaults messageHistoryMaxAgeMs to 48 hours in milliseconds', () => {
1204
+ const { config } = parseConfig(env());
1205
+ expect(config.messageHistoryMaxAgeMs).toBe(48 * 3_600_000);
1206
+ });
1207
+ it('parses DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS and converts to ms', () => {
1208
+ const { config } = parseConfig(env({ DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS: '24' }));
1209
+ expect(config.messageHistoryMaxAgeMs).toBe(24 * 3_600_000);
1210
+ });
1195
1211
  });
@@ -452,4 +452,117 @@ describe('runCronSync', () => {
452
452
  expect(result.orphansDetected).toBe(1);
453
453
  expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-orphan' }), expect.stringContaining('orphan'));
454
454
  });
455
+ it('sync preserves disabled flag without modifying it', async () => {
456
+ const forum = makeForum([{ id: 'thread-1', name: 'Paused Job', parentId: 'forum-1' }]);
457
+ const client = makeClient(forum);
458
+ const statsStore = makeStatsStore([
459
+ makeRecord({
460
+ cronId: 'cron-1',
461
+ threadId: 'thread-1',
462
+ disabled: true,
463
+ pauseSource: 'user',
464
+ cadence: 'daily',
465
+ purposeTags: ['monitoring'],
466
+ model: 'haiku',
467
+ }),
468
+ ]);
469
+ const scheduler = makeScheduler([
470
+ { id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Paused Job', schedule: '0 7 * * *', prompt: 'Check things' },
471
+ ]);
472
+ await runCronSync({
473
+ client: client,
474
+ forumId: 'forum-1',
475
+ scheduler,
476
+ statsStore,
477
+ runtime: makeMockRuntime('monitoring'),
478
+ tagMap: { ...defaultTagMap },
479
+ autoTag: false,
480
+ autoTagModel: 'haiku',
481
+ cwd: '/tmp',
482
+ log: mockLog(),
483
+ throttleMs: 0,
484
+ });
485
+ // Verify disabled and pauseSource were not altered by sync
486
+ const record = statsStore.getRecord('cron-1');
487
+ expect(record?.disabled).toBe(true);
488
+ expect(record?.pauseSource).toBe('user');
489
+ });
490
+ it('phase 5: recreating disabled cron calls scheduler.disable on new thread', async () => {
491
+ const create = vi.fn(async ({ name, message }) => ({
492
+ id: 'thread-new',
493
+ name,
494
+ message,
495
+ }));
496
+ const forum = makeForum([], { create });
497
+ const client = makeClient(forum);
498
+ const statsStore = makeStatsStore([
499
+ makeRecord({
500
+ cronId: 'cron-disabled',
501
+ threadId: 'thread-old',
502
+ disabled: true,
503
+ pauseSource: 'user',
504
+ cadence: 'daily',
505
+ schedule: '0 7 * * *',
506
+ timezone: 'UTC',
507
+ channel: 'general',
508
+ prompt: 'Disabled job that needs thread recreation.',
509
+ projectionStatus: 'missing',
510
+ projectionHash: 'stale-hash',
511
+ }),
512
+ ]);
513
+ const scheduler = makeScheduler([]);
514
+ const result = await runCronSync({
515
+ client: client,
516
+ forumId: 'forum-1',
517
+ scheduler,
518
+ statsStore,
519
+ runtime: makeMockRuntime('monitoring'),
520
+ tagMap: { ...defaultTagMap },
521
+ autoTag: false,
522
+ autoTagModel: 'haiku',
523
+ cwd: '/tmp',
524
+ log: mockLog(),
525
+ throttleMs: 0,
526
+ });
527
+ expect(result.projectionsRepaired).toBe(1);
528
+ // Scheduler should have been called: register then disable
529
+ expect(scheduler.register).toHaveBeenCalled();
530
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-new');
531
+ // Record should still be disabled after recreation
532
+ const record = statsStore.getRecord('cron-disabled');
533
+ expect(record?.disabled).toBe(true);
534
+ expect(record?.threadId).toBe('thread-new');
535
+ });
536
+ it('phase 3: updates status messages for disabled crons', async () => {
537
+ const forum = makeForum([{ id: 'thread-1', name: 'Paused Job', parentId: 'forum-1' }]);
538
+ const client = makeClient(forum);
539
+ const statsStore = makeStatsStore([
540
+ makeRecord({
541
+ cronId: 'cron-1',
542
+ threadId: 'thread-1',
543
+ disabled: true,
544
+ pauseSource: 'user',
545
+ cadence: 'daily',
546
+ model: 'haiku',
547
+ }),
548
+ ]);
549
+ const scheduler = makeScheduler([
550
+ { id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Paused Job', schedule: '0 7 * * *', prompt: 'Check things' },
551
+ ]);
552
+ const result = await runCronSync({
553
+ client: client,
554
+ forumId: 'forum-1',
555
+ scheduler,
556
+ statsStore,
557
+ runtime: makeMockRuntime('monitoring'),
558
+ tagMap: { ...defaultTagMap },
559
+ autoTag: false,
560
+ autoTagModel: 'haiku',
561
+ cwd: '/tmp',
562
+ log: mockLog(),
563
+ throttleMs: 0,
564
+ });
565
+ // Disabled crons should still get status message updates
566
+ expect(result.statusMessagesUpdated).toBe(1);
567
+ });
455
568
  });
@@ -1,5 +1,5 @@
1
1
  import { ChannelType, EmbedBuilder } from 'discord.js';
2
- import { parseCronDefinition } from './parser.js';
2
+ import { parseCronDefinition, parseStarterContent } from './parser.js';
3
3
  import { generateCronId, parseCronIdFromContent } from './run-stats.js';
4
4
  import { detectCadence } from './cadence.js';
5
5
  import { ensureStatusMessage } from './discord-sync.js';
@@ -106,15 +106,24 @@ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
106
106
  }
107
107
  return false;
108
108
  }
109
- const def = await parseCronDefinition(starter.content, runtime, { model: opts.cronModel, cwd: opts.cwd });
109
+ // Try deterministic parse of bot-formatted starter first (no LLM needed).
110
+ const deterministicDef = parseStarterContent(starter.content);
111
+ const def = deterministicDef ?? await parseCronDefinition(starter.content, runtime, { model: opts.cronModel, cwd: opts.cwd });
110
112
  if (!def) {
111
- opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed');
112
- scheduler.disable(thread.id);
113
- try {
114
- await thread.send('Could not parse this cron definition. Please edit the starter message with a clearer schedule, timezone, target channel, and instruction.');
113
+ if (opts.isNew) {
114
+ // Fresh thread — user just created it and needs feedback.
115
+ opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed');
116
+ scheduler.disable(thread.id);
117
+ try {
118
+ await thread.send('Could not parse this cron definition. Please edit the starter message with a clearer schedule, timezone, target channel, and instruction.');
119
+ }
120
+ catch {
121
+ // Ignore send failures.
122
+ }
115
123
  }
116
- catch {
117
- // Ignore send failures.
124
+ else {
125
+ // Boot/re-parse soft failure: leave unregistered so cron-sync can retry.
126
+ opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed (boot re-parse), skipping without disabling');
118
127
  }
119
128
  return false;
120
129
  }
@@ -417,15 +426,10 @@ export async function initCronForum(opts) {
417
426
  // Archive state changed.
418
427
  if (oldThread.archived !== newThread.archived) {
419
428
  if (newThread.archived) {
420
- log?.info({ threadId: newThread.id }, 'cron:forum thread archived, disabling');
421
- scheduler.disable(newThread.id);
422
- // Persist disabled state.
423
- if (statsStore) {
424
- const record = statsStore.getRecordByThreadId(newThread.id);
425
- if (record) {
426
- void statsStore.upsertRecord(record.cronId, newThread.id, { disabled: true }).catch(() => { });
427
- }
428
- }
429
+ // Auto-archive (Discord inactivity) must NOT disable crons.
430
+ // Only explicit cronPause commands persist disabled: true.
431
+ // The cron keeps firing to its target channel; the thread is just UI.
432
+ log?.info({ threadId: newThread.id }, 'cron:forum thread archived (cron continues)');
429
433
  }
430
434
  else {
431
435
  // Reject unarchived manual threads not already grandfathered into the scheduler.
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi, beforeEach } from 'vitest';
2
2
  import { ChannelType } from 'discord.js';
3
3
  vi.mock('./parser.js', () => {
4
- return { parseCronDefinition: vi.fn() };
4
+ return { parseCronDefinition: vi.fn(), parseStarterContent: vi.fn() };
5
5
  });
6
6
  // Mock ensureStatusMessage and detectCadence to avoid side effects.
7
7
  vi.mock('./discord-sync.js', async (importOriginal) => {
@@ -64,11 +64,13 @@ function makeScheduler() {
64
64
  describe('initCronForum', () => {
65
65
  let initCronForum;
66
66
  let parseCronDefinition;
67
+ let parseStarterContent;
67
68
  beforeEach(async () => {
68
69
  // Dynamic import after mocks are registered.
69
70
  ({ initCronForum } = await import('./forum-sync.js'));
70
- ({ parseCronDefinition } = await import('./parser.js'));
71
+ ({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
71
72
  vi.mocked(parseCronDefinition).mockReset();
73
+ vi.mocked(parseStarterContent).mockReset();
72
74
  });
73
75
  it('does not register when starter author is not allowlisted', async () => {
74
76
  const thread = makeThread();
@@ -134,7 +136,7 @@ describe('initCronForum', () => {
134
136
  expect(scheduler.register).toHaveBeenCalledOnce();
135
137
  expect(scheduler.disable).not.toHaveBeenCalled();
136
138
  });
137
- it('disables and reports when parsing fails', async () => {
139
+ it('soft-fails on boot re-parse (isNew: false) when both parsers return null — no disable, no error post', async () => {
138
140
  const thread = makeThread();
139
141
  thread.fetchStarterMessage.mockResolvedValue({
140
142
  id: 'm1',
@@ -157,8 +159,9 @@ describe('initCronForum', () => {
157
159
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
158
160
  });
159
161
  expect(scheduler.register).not.toHaveBeenCalled();
160
- expect(scheduler.disable).toHaveBeenCalledOnce();
161
- expect(thread.send).toHaveBeenCalledOnce();
162
+ // Boot re-parse: soft failure — no disable, no error message.
163
+ expect(scheduler.disable).not.toHaveBeenCalled();
164
+ expect(thread.send).not.toHaveBeenCalled();
162
165
  });
163
166
  it('registers when parsing succeeds and author is allowlisted', async () => {
164
167
  const thread = makeThread();
@@ -592,14 +595,129 @@ describe('initCronForum', () => {
592
595
  // Should disable the job because stats record says disabled: true.
593
596
  expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
594
597
  });
598
+ it('uses deterministic parser when no stats record exists (AI parser not called)', async () => {
599
+ const thread = makeThread();
600
+ const botContent = [
601
+ '**Schedule:** `0 7 * * 1-5` (America/Los_Angeles)',
602
+ '**Channel:** #general',
603
+ '**Input:** prompt-only',
604
+ '',
605
+ 'Check the weather.',
606
+ ].join('\n');
607
+ thread.fetchStarterMessage.mockResolvedValue({
608
+ id: 'm1',
609
+ content: botContent,
610
+ author: { id: 'bot-user-1' },
611
+ react: vi.fn().mockResolvedValue(undefined),
612
+ });
613
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
614
+ const forum = makeForum([thread]);
615
+ const client = makeClient(forum);
616
+ const scheduler = makeScheduler();
617
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
618
+ // No stats store — fast path is skipped, falls into loadThreadAsCron.
619
+ // Deterministic parser returns a valid def.
620
+ vi.mocked(parseStarterContent).mockReturnValue({
621
+ triggerType: 'schedule',
622
+ schedule: '0 7 * * 1-5',
623
+ timezone: 'America/Los_Angeles',
624
+ channel: 'general',
625
+ prompt: 'Check the weather.',
626
+ });
627
+ await initCronForum({
628
+ client: client,
629
+ forumChannelNameOrId: 'forum-1',
630
+ allowUserIds: new Set(['u-allowed']),
631
+ scheduler: scheduler,
632
+ runtime: {},
633
+ cronModel: 'haiku',
634
+ cwd: '/tmp',
635
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
636
+ });
637
+ // Deterministic parser was called, AI parser was NOT called.
638
+ expect(parseStarterContent).toHaveBeenCalledWith(botContent);
639
+ expect(parseCronDefinition).not.toHaveBeenCalled();
640
+ expect(scheduler.register).toHaveBeenCalledOnce();
641
+ });
642
+ it('falls through to AI parser when deterministic parse returns null', async () => {
643
+ const thread = makeThread();
644
+ thread.fetchStarterMessage.mockResolvedValue({
645
+ id: 'm1',
646
+ content: 'Every weekday at 7am Pacific, check the weather and post to #general',
647
+ author: { id: 'u-allowed' },
648
+ react: vi.fn().mockResolvedValue(undefined),
649
+ });
650
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
651
+ const forum = makeForum([thread]);
652
+ const client = makeClient(forum);
653
+ const scheduler = makeScheduler();
654
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
655
+ // Deterministic parser returns null (freeform text).
656
+ vi.mocked(parseStarterContent).mockReturnValue(null);
657
+ vi.mocked(parseCronDefinition).mockResolvedValue({
658
+ triggerType: 'schedule',
659
+ schedule: '0 7 * * 1-5',
660
+ timezone: 'America/Los_Angeles',
661
+ channel: 'general',
662
+ prompt: 'Check the weather.',
663
+ });
664
+ await initCronForum({
665
+ client: client,
666
+ forumChannelNameOrId: 'forum-1',
667
+ allowUserIds: new Set(['u-allowed']),
668
+ scheduler: scheduler,
669
+ runtime: {},
670
+ cronModel: 'haiku',
671
+ cwd: '/tmp',
672
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
673
+ });
674
+ // Deterministic parser was called first, then AI parser.
675
+ expect(parseStarterContent).toHaveBeenCalled();
676
+ expect(parseCronDefinition).toHaveBeenCalled();
677
+ expect(scheduler.register).toHaveBeenCalledOnce();
678
+ });
679
+ it('new thread (isNew: true) still posts error when both parsers fail', async () => {
680
+ // This test uses the threadCreate listener which passes isNew: true.
681
+ const forum = makeForum([]);
682
+ const client = makeClient(forum);
683
+ const scheduler = makeScheduler();
684
+ scheduler.getJob.mockReturnValue(undefined);
685
+ await initCronForum({
686
+ client: client,
687
+ forumChannelNameOrId: 'forum-1',
688
+ allowUserIds: new Set(['u-allowed']),
689
+ scheduler: scheduler,
690
+ runtime: {},
691
+ cronModel: 'haiku',
692
+ cwd: '/tmp',
693
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
694
+ });
695
+ const threadCreateCallbacks = client._listeners['threadCreate'] ?? [];
696
+ const listener = threadCreateCallbacks[0];
697
+ vi.mocked(parseStarterContent).mockReturnValue(null);
698
+ vi.mocked(parseCronDefinition).mockResolvedValue(null);
699
+ const thread = makeThread({ id: 'thread-new-fail', parentId: 'forum-1' });
700
+ thread.fetchStarterMessage.mockResolvedValue({
701
+ id: 'm1',
702
+ content: 'total nonsense',
703
+ author: { id: 'bot-user-1' },
704
+ react: vi.fn().mockResolvedValue(undefined),
705
+ });
706
+ await listener(thread);
707
+ // isNew: true — should disable and post error message.
708
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-new-fail');
709
+ expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('Could not parse'));
710
+ });
595
711
  });
596
712
  describe('threadCreate listener', () => {
597
713
  let initCronForum;
598
714
  let parseCronDefinition;
715
+ let parseStarterContent;
599
716
  beforeEach(async () => {
600
717
  ({ initCronForum } = await import('./forum-sync.js'));
601
- ({ parseCronDefinition } = await import('./parser.js'));
718
+ ({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
602
719
  vi.mocked(parseCronDefinition).mockReset();
720
+ vi.mocked(parseStarterContent).mockReset();
603
721
  });
604
722
  async function setupAndGetListener(opts = {}) {
605
723
  const forum = makeForum([]);
@@ -712,10 +830,12 @@ describe('threadCreate listener', () => {
712
830
  describe('threadUpdate listener', () => {
713
831
  let initCronForum;
714
832
  let parseCronDefinition;
833
+ let parseStarterContent;
715
834
  beforeEach(async () => {
716
835
  ({ initCronForum } = await import('./forum-sync.js'));
717
- ({ parseCronDefinition } = await import('./parser.js'));
836
+ ({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
718
837
  vi.mocked(parseCronDefinition).mockReset();
838
+ vi.mocked(parseStarterContent).mockReset();
719
839
  });
720
840
  async function setupAndGetListener(opts = {}) {
721
841
  const forum = makeForum([]);
@@ -730,11 +850,64 @@ describe('threadUpdate listener', () => {
730
850
  cronModel: 'haiku',
731
851
  cwd: '/tmp',
732
852
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
853
+ statsStore: opts.statsStore,
733
854
  });
734
855
  const threadUpdateCallbacks = client._listeners['threadUpdate'] ?? [];
735
856
  expect(threadUpdateCallbacks.length).toBeGreaterThan(0);
736
857
  return { listener: threadUpdateCallbacks[0], scheduler, client };
737
858
  }
859
+ it('does not disable scheduler or persist disabled when thread is archived', async () => {
860
+ const scheduler = makeScheduler();
861
+ const statsStore = {
862
+ getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
863
+ getRecord: vi.fn(),
864
+ getStore: vi.fn().mockReturnValue({ jobs: {} }),
865
+ upsertRecord: vi.fn(async () => ({})),
866
+ };
867
+ const { listener } = await setupAndGetListener({ scheduler, statsStore });
868
+ const oldThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
869
+ const newThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: true });
870
+ await listener(oldThread, newThread);
871
+ // Archiving should NOT disable the scheduler — cron keeps running.
872
+ expect(scheduler.disable).not.toHaveBeenCalled();
873
+ // Should NOT persist disabled: true to the stats store.
874
+ expect(statsStore.upsertRecord).not.toHaveBeenCalled();
875
+ });
876
+ it('cron survives archive+unarchive cycle without becoming stuck disabled', async () => {
877
+ const scheduler = makeScheduler();
878
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
879
+ vi.mocked(parseCronDefinition).mockResolvedValue({
880
+ triggerType: 'schedule',
881
+ schedule: '0 7 * * *',
882
+ timezone: 'UTC',
883
+ channel: 'general',
884
+ prompt: 'Say hello.',
885
+ });
886
+ const statsStore = {
887
+ getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
888
+ getRecord: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
889
+ getStore: vi.fn().mockReturnValue({ jobs: {} }),
890
+ upsertRecord: vi.fn(async () => ({})),
891
+ };
892
+ const { listener } = await setupAndGetListener({ scheduler, statsStore });
893
+ // Step 1: archive
894
+ const oldThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
895
+ const archivedThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: true });
896
+ await listener(oldThread, archivedThread);
897
+ // Step 2: unarchive
898
+ const unarchivedThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
899
+ unarchivedThread.fetchStarterMessage.mockResolvedValue({
900
+ id: 'm1',
901
+ content: 'every day at 7am say hello',
902
+ author: { id: 'u-allowed' },
903
+ react: vi.fn().mockResolvedValue(undefined),
904
+ });
905
+ unarchivedThread.messages = { fetch: vi.fn().mockResolvedValue(new Map()) };
906
+ await listener(archivedThread, unarchivedThread);
907
+ // The cron should be re-registered, NOT disabled.
908
+ expect(scheduler.register).toHaveBeenCalled();
909
+ expect(scheduler.disable).not.toHaveBeenCalled();
910
+ });
738
911
  it('rejects unarchived manual thread not in scheduler', async () => {
739
912
  const scheduler = makeScheduler();
740
913
  scheduler.getJob.mockReturnValue(undefined);
@@ -1,5 +1,68 @@
1
1
  import { resolveModel } from '../runtime/model-tiers.js';
2
2
  import { getDefaultTimezone } from './default-timezone.js';
3
+ /**
4
+ * Deterministic parser for bot-formatted starter messages produced by
5
+ * `buildStarterContent` in `src/discord/actions-crons.ts`.
6
+ *
7
+ * Expected format:
8
+ * **Schedule:** `<schedule>` (<timezone>)
9
+ * **Channel:** #<channel>
10
+ * **Input:** <input-mode>
11
+ * [optional ```bash block```]
12
+ *
13
+ * <prompt text>
14
+ *
15
+ * Returns null if any required field is missing — non-bot-formatted
16
+ * messages silently fall through to AI.
17
+ */
18
+ export function parseStarterContent(text) {
19
+ // Extract schedule and timezone from: **Schedule:** `<schedule>` (<timezone>)
20
+ const scheduleMatch = text.match(/\*\*Schedule:\*\*\s*`([^`]+)`\s*\(([^)]+)\)/);
21
+ if (!scheduleMatch)
22
+ return null;
23
+ const schedule = scheduleMatch[1].trim();
24
+ const timezone = scheduleMatch[2].trim();
25
+ if (!schedule || !timezone)
26
+ return null;
27
+ // Extract channel from: **Channel:** #<channel>
28
+ const channelMatch = text.match(/\*\*Channel:\*\*\s*#(\S+)/);
29
+ if (!channelMatch)
30
+ return null;
31
+ const channel = channelMatch[1].trim();
32
+ if (!channel)
33
+ return null;
34
+ // The prompt is everything after the blank-line separator following the metadata block.
35
+ // The metadata block ends after the **Input:** line (and optional ```bash``` block).
36
+ // Split on the first blank line that follows the metadata lines.
37
+ const lines = text.split('\n');
38
+ let blankLineIdx = -1;
39
+ let pastMetadata = false;
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i];
42
+ // Track when we've passed the metadata lines (lines starting with ** or code fences)
43
+ if (line.startsWith('**') || line.startsWith('```')) {
44
+ pastMetadata = true;
45
+ continue;
46
+ }
47
+ // After metadata, find the first blank line
48
+ if (pastMetadata && line.trim() === '') {
49
+ blankLineIdx = i;
50
+ break;
51
+ }
52
+ }
53
+ if (blankLineIdx === -1)
54
+ return null;
55
+ const prompt = lines.slice(blankLineIdx + 1).join('\n').trim();
56
+ if (!prompt)
57
+ return null;
58
+ return {
59
+ triggerType: 'schedule',
60
+ schedule,
61
+ timezone,
62
+ channel,
63
+ prompt,
64
+ };
65
+ }
3
66
  function buildSystemPrompt() {
4
67
  const defaultTz = getDefaultTimezone();
5
68
  return `You are a cron definition parser. Extract a cron schedule from a natural-language task description.