discoclaw 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli/daemon-installer.js +32 -16
- package/dist/cli/daemon-installer.test.js +23 -3
- package/dist/cli/index.js +4 -2
- package/dist/cron/executor.js +5 -2
- package/dist/discord/actions.js +11 -6
- package/dist/discord/actions.test.js +33 -1
- package/dist/discord/deferred-runner.js +2 -1
- package/dist/discord/message-coordinator.js +10 -2
- package/dist/discord/output-common.js +15 -0
- package/dist/discord/output-common.test.js +37 -1
- package/dist/discord/reaction-handler.js +6 -2
- package/dist/discord-followup.test.js +1 -10
- package/dist/tasks/thread-contracts.test.js +35 -0
- package/dist/tasks/thread-lifecycle-ops.js +1 -1
- package/dist/workspace-bootstrap.test.js +5 -3
- package/package.json +1 -1
- package/templates/workspace/AGENTS.md +6 -6
- package/templates/workspace/TOOLS.md +129 -1
package/README.md
CHANGED
|
@@ -136,6 +136,10 @@ Full step-by-step guide: [docs/discord-bot-setup.md](docs/discord-bot-setup.md)
|
|
|
136
136
|
```bash
|
|
137
137
|
discoclaw install-daemon
|
|
138
138
|
```
|
|
139
|
+
Optional: pass `--service-name <name>` to use a custom service name (useful on macOS when running multiple instances, or to match your own naming convention):
|
|
140
|
+
```bash
|
|
141
|
+
discoclaw install-daemon --service-name personal
|
|
142
|
+
```
|
|
139
143
|
|
|
140
144
|
#### From source (contributors)
|
|
141
145
|
|
|
@@ -157,6 +161,8 @@ pnpm dev
|
|
|
157
161
|
```bash
|
|
158
162
|
npm update -g discoclaw
|
|
159
163
|
discoclaw install-daemon # re-register the service after updating
|
|
164
|
+
# If you used a custom service name, pass it again:
|
|
165
|
+
# discoclaw install-daemon --service-name personal
|
|
160
166
|
```
|
|
161
167
|
|
|
162
168
|
**From source:**
|
|
@@ -21,6 +21,19 @@ export function resolvePackageRoot() {
|
|
|
21
21
|
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
return path.resolve(selfDir, '..', '..');
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Parses --service-name <name> from argv.
|
|
26
|
+
* Defaults to 'discoclaw' if not provided.
|
|
27
|
+
*/
|
|
28
|
+
export function parseServiceName(argv = process.argv) {
|
|
29
|
+
const idx = argv.indexOf('--service-name');
|
|
30
|
+
if (idx !== -1 && idx + 1 < argv.length) {
|
|
31
|
+
const name = argv[idx + 1];
|
|
32
|
+
if (name && !name.startsWith('-'))
|
|
33
|
+
return name;
|
|
34
|
+
}
|
|
35
|
+
return 'discoclaw';
|
|
36
|
+
}
|
|
24
37
|
// ── Rendering helpers ──────────────────────────────────────────────────────
|
|
25
38
|
/**
|
|
26
39
|
* Parses a .env file into key-value pairs.
|
|
@@ -71,8 +84,9 @@ export function renderSystemdUnit(packageRoot, cwd) {
|
|
|
71
84
|
* Since launchd has no EnvironmentFile equivalent, env vars are baked in
|
|
72
85
|
* from the parsed .env at install time.
|
|
73
86
|
*/
|
|
74
|
-
export function renderLaunchdPlist(packageRoot, cwd, envVars) {
|
|
87
|
+
export function renderLaunchdPlist(packageRoot, cwd, envVars, serviceName = 'discoclaw') {
|
|
75
88
|
const entryPoint = path.join(packageRoot, 'dist', 'index.js');
|
|
89
|
+
const label = `com.discoclaw.${serviceName}`;
|
|
76
90
|
const envEntries = Object.entries(envVars)
|
|
77
91
|
.map(([k, v]) => `\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
|
|
78
92
|
.join('\n');
|
|
@@ -82,7 +96,7 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars) {
|
|
|
82
96
|
'<plist version="1.0">',
|
|
83
97
|
'<dict>',
|
|
84
98
|
'\t<key>Label</key>',
|
|
85
|
-
|
|
99
|
+
`\t<string>${label}</string>`,
|
|
86
100
|
'\t<key>ProgramArguments</key>',
|
|
87
101
|
'\t<array>',
|
|
88
102
|
'\t\t<string>/usr/bin/node</string>',
|
|
@@ -106,9 +120,9 @@ export function renderLaunchdPlist(packageRoot, cwd, envVars) {
|
|
|
106
120
|
return lines.join('\n');
|
|
107
121
|
}
|
|
108
122
|
// ── Platform installers ────────────────────────────────────────────────────
|
|
109
|
-
async function installSystemd(packageRoot, cwd, ask) {
|
|
123
|
+
async function installSystemd(packageRoot, cwd, serviceName, ask) {
|
|
110
124
|
const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
111
|
-
const servicePath = path.join(serviceDir,
|
|
125
|
+
const servicePath = path.join(serviceDir, `${serviceName}.service`);
|
|
112
126
|
if (fs.existsSync(servicePath)) {
|
|
113
127
|
const answer = await ask(`Service file already exists at ${servicePath}. Overwrite? [y/N] `);
|
|
114
128
|
if (answer.trim().toLowerCase() !== 'y') {
|
|
@@ -128,9 +142,9 @@ async function installSystemd(packageRoot, cwd, ask) {
|
|
|
128
142
|
console.error(`systemctl daemon-reload failed: ${err.message}\n`);
|
|
129
143
|
process.exit(1);
|
|
130
144
|
}
|
|
131
|
-
console.log(
|
|
145
|
+
console.log(`Enabling and starting ${serviceName} service...`);
|
|
132
146
|
try {
|
|
133
|
-
execFileSync('systemctl', ['--user', 'enable', '--now',
|
|
147
|
+
execFileSync('systemctl', ['--user', 'enable', '--now', serviceName]);
|
|
134
148
|
}
|
|
135
149
|
catch (err) {
|
|
136
150
|
console.error(`systemctl enable/start failed: ${err.message}\n`);
|
|
@@ -138,14 +152,15 @@ async function installSystemd(packageRoot, cwd, ask) {
|
|
|
138
152
|
}
|
|
139
153
|
console.log('DiscoClaw daemon installed and started.\n');
|
|
140
154
|
console.log('Useful commands:');
|
|
141
|
-
console.log(
|
|
142
|
-
console.log(
|
|
143
|
-
console.log(
|
|
155
|
+
console.log(` journalctl --user -u ${serviceName}.service -f # tail logs`);
|
|
156
|
+
console.log(` systemctl --user status ${serviceName} # check status`);
|
|
157
|
+
console.log(` systemctl --user stop ${serviceName} # stop the service`);
|
|
144
158
|
console.log('');
|
|
145
159
|
}
|
|
146
|
-
async function installLaunchd(packageRoot, cwd, envPath, ask) {
|
|
160
|
+
async function installLaunchd(packageRoot, cwd, envPath, serviceName, ask) {
|
|
147
161
|
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
148
|
-
const
|
|
162
|
+
const label = `com.discoclaw.${serviceName}`;
|
|
163
|
+
const plistPath = path.join(agentsDir, `${label}.plist`);
|
|
149
164
|
if (fs.existsSync(plistPath)) {
|
|
150
165
|
const answer = await ask(`Plist already exists at ${plistPath}. Overwrite? [y/N] `);
|
|
151
166
|
if (answer.trim().toLowerCase() !== 'y') {
|
|
@@ -155,14 +170,14 @@ async function installLaunchd(packageRoot, cwd, envPath, ask) {
|
|
|
155
170
|
}
|
|
156
171
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
157
172
|
const envVars = parseEnvFile(envContent);
|
|
158
|
-
const plist = renderLaunchdPlist(packageRoot, cwd, envVars);
|
|
173
|
+
const plist = renderLaunchdPlist(packageRoot, cwd, envVars, serviceName);
|
|
159
174
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
160
175
|
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
161
176
|
console.log(`Wrote ${plistPath}\n`);
|
|
162
177
|
console.log('Note: On macOS, .env changes require re-running `discoclaw install-daemon`.\n' +
|
|
163
178
|
' Environment variables are baked into the plist at install time.\n');
|
|
164
179
|
const uid = process.getuid();
|
|
165
|
-
const target = `gui/${uid}
|
|
180
|
+
const target = `gui/${uid}/${label}`;
|
|
166
181
|
// Idempotent: bootout first (ignore failure if agent is not currently loaded)
|
|
167
182
|
try {
|
|
168
183
|
execFileSync('launchctl', ['bootout', target]);
|
|
@@ -181,7 +196,7 @@ async function installLaunchd(packageRoot, cwd, envPath, ask) {
|
|
|
181
196
|
console.log('DiscoClaw daemon installed and started.\n');
|
|
182
197
|
console.log('Useful commands:');
|
|
183
198
|
console.log(" log stream --predicate 'process == \"node\"' # tail logs");
|
|
184
|
-
console.log(
|
|
199
|
+
console.log(` launchctl list ${label} # check status`);
|
|
185
200
|
console.log(` launchctl bootout ${target} # stop and unload`);
|
|
186
201
|
console.log('');
|
|
187
202
|
}
|
|
@@ -209,14 +224,15 @@ export async function runDaemonInstaller() {
|
|
|
209
224
|
'Make sure the package is properly installed (try running `npm install -g discoclaw` again).\n');
|
|
210
225
|
process.exit(1);
|
|
211
226
|
}
|
|
227
|
+
const serviceName = parseServiceName();
|
|
212
228
|
const rl = readline.createInterface({ input, output });
|
|
213
229
|
const ask = (prompt) => rl.question(prompt);
|
|
214
230
|
try {
|
|
215
231
|
if (platform === 'linux') {
|
|
216
|
-
await installSystemd(packageRoot, cwd, ask);
|
|
232
|
+
await installSystemd(packageRoot, cwd, serviceName, ask);
|
|
217
233
|
}
|
|
218
234
|
else {
|
|
219
|
-
await installLaunchd(packageRoot, cwd, envPath, ask);
|
|
235
|
+
await installLaunchd(packageRoot, cwd, envPath, serviceName, ask);
|
|
220
236
|
}
|
|
221
237
|
}
|
|
222
238
|
finally {
|
|
@@ -16,7 +16,7 @@ vi.mock('node:readline/promises', () => ({
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { createInterface } from 'node:readline/promises';
|
|
19
|
-
import { parseEnvFile, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
|
|
19
|
+
import { parseEnvFile, parseServiceName, renderSystemdUnit, renderLaunchdPlist, runDaemonInstaller, } from './daemon-installer.js';
|
|
20
20
|
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
21
21
|
function makeReadline(answers = []) {
|
|
22
22
|
return {
|
|
@@ -47,6 +47,21 @@ describe('parseEnvFile', () => {
|
|
|
47
47
|
expect(parseEnvFile('# just comments\n')).toEqual({});
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
// ── parseServiceName ───────────────────────────────────────────────────────
|
|
51
|
+
describe('parseServiceName', () => {
|
|
52
|
+
it('returns "discoclaw" when --service-name is absent', () => {
|
|
53
|
+
expect(parseServiceName(['node', 'discoclaw', 'install-daemon'])).toBe('discoclaw');
|
|
54
|
+
});
|
|
55
|
+
it('returns the provided name when --service-name is present', () => {
|
|
56
|
+
expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name', 'personal'])).toBe('personal');
|
|
57
|
+
});
|
|
58
|
+
it('ignores --service-name if the next token starts with a dash', () => {
|
|
59
|
+
expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name', '--other-flag'])).toBe('discoclaw');
|
|
60
|
+
});
|
|
61
|
+
it('ignores --service-name if it is the last token with no value', () => {
|
|
62
|
+
expect(parseServiceName(['node', 'discoclaw', 'install-daemon', '--service-name'])).toBe('discoclaw');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
50
65
|
// ── renderSystemdUnit ──────────────────────────────────────────────────────
|
|
51
66
|
describe('renderSystemdUnit', () => {
|
|
52
67
|
it('produces correct ExecStart with /usr/bin/node and dist/index.js', () => {
|
|
@@ -73,10 +88,15 @@ describe('renderSystemdUnit', () => {
|
|
|
73
88
|
// ── renderLaunchdPlist ─────────────────────────────────────────────────────
|
|
74
89
|
describe('renderLaunchdPlist', () => {
|
|
75
90
|
const envVars = { DISCORD_TOKEN: 'tok', FOO: 'bar' };
|
|
76
|
-
it('produces valid XML plist with
|
|
91
|
+
it('produces valid XML plist with default label', () => {
|
|
77
92
|
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
|
78
93
|
expect(plist).toContain('<?xml version="1.0"');
|
|
79
|
-
expect(plist).toContain('<string>com.discoclaw.
|
|
94
|
+
expect(plist).toContain('<string>com.discoclaw.discoclaw</string>');
|
|
95
|
+
});
|
|
96
|
+
it('uses custom service name in label when provided', () => {
|
|
97
|
+
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars, 'personal');
|
|
98
|
+
expect(plist).toContain('<string>com.discoclaw.personal</string>');
|
|
99
|
+
expect(plist).not.toContain('com.discoclaw.discoclaw');
|
|
80
100
|
});
|
|
81
101
|
it('sets ProgramArguments to /usr/bin/node and dist/index.js', () => {
|
|
82
102
|
const plist = renderLaunchdPlist(PACKAGE_ROOT, CWD, envVars);
|
package/dist/cli/index.js
CHANGED
|
@@ -34,8 +34,10 @@ function printHelp(ver) {
|
|
|
34
34
|
console.log(`discoclaw v${ver} — Personal AI orchestrator\n` +
|
|
35
35
|
`\nUsage: discoclaw <command>\n` +
|
|
36
36
|
`\nCommands:\n` +
|
|
37
|
-
` init
|
|
38
|
-
` install-daemon
|
|
37
|
+
` init Interactive setup wizard — creates .env and workspace/\n` +
|
|
38
|
+
` install-daemon [--service-name <name>] Register discoclaw as a persistent background service\n` +
|
|
39
|
+
` Use --service-name to run multiple instances side-by-side.\n` +
|
|
40
|
+
` Defaults to "discoclaw".\n` +
|
|
39
41
|
`\nOptions:\n` +
|
|
40
42
|
` -v, --version Print version\n` +
|
|
41
43
|
` -h, --help Print this help\n`);
|
package/dist/cron/executor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { acquireCronLock, releaseCronLock } from './job-lock.js';
|
|
2
2
|
import { resolveChannel } from '../discord/action-utils.js';
|
|
3
3
|
import * as discordActions from '../discord/actions.js';
|
|
4
|
-
import { sendChunks, appendUnavailableActionTypesNotice } from '../discord/output-common.js';
|
|
4
|
+
import { sendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from '../discord/output-common.js';
|
|
5
5
|
import { buildPromptPreamble, loadWorkspacePaFiles, inlineContextFiles, resolveEffectiveTools } from '../discord/prompt-common.js';
|
|
6
6
|
import { ensureStatusMessage } from './discord-sync.js';
|
|
7
7
|
import { globalMetrics } from '../observability/metrics.js';
|
|
@@ -199,11 +199,13 @@ export async function executeCronJob(job, ctx) {
|
|
|
199
199
|
}
|
|
200
200
|
let processedText = output;
|
|
201
201
|
let strippedUnrecognizedTypes = [];
|
|
202
|
+
let parseFailuresCount = 0;
|
|
202
203
|
// Handle Discord actions if enabled.
|
|
203
204
|
if (ctx.discordActionsEnabled) {
|
|
204
205
|
const parsed = discordActions.parseDiscordActions(processedText, ctx.actionFlags);
|
|
205
206
|
const { cleanText, actions } = parsed;
|
|
206
207
|
strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
|
|
208
|
+
parseFailuresCount = parsed.parseFailures;
|
|
207
209
|
if (actions.length > 0) {
|
|
208
210
|
const actCtx = {
|
|
209
211
|
guild,
|
|
@@ -232,7 +234,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
232
234
|
? cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
|
|
233
235
|
: cleanText.trimEnd();
|
|
234
236
|
// When all display lines were suppressed and there's no prose, skip posting.
|
|
235
|
-
if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0) {
|
|
237
|
+
if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0 && parseFailuresCount === 0) {
|
|
236
238
|
ctx.log?.info({ jobId: job.id }, 'cron:reply suppressed (actions-only, no display text)');
|
|
237
239
|
}
|
|
238
240
|
if (ctx.status) {
|
|
@@ -248,6 +250,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
|
|
253
|
+
processedText = appendParseFailureNotice(processedText, parseFailuresCount);
|
|
251
254
|
await sendChunks(channelForSend, processedText, collectedImages);
|
|
252
255
|
ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel }, 'cron:exec done');
|
|
253
256
|
// Record successful run.
|
package/dist/discord/actions.js
CHANGED
|
@@ -306,16 +306,17 @@ function collectParsedAction(parsed, flags, validTypes, actions, strippedUnrecog
|
|
|
306
306
|
strippedUnrecognizedTypes.push(type);
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
|
-
function parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes) {
|
|
309
|
+
function parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef) {
|
|
310
310
|
try {
|
|
311
311
|
const parsed = JSON.parse(jsonStr);
|
|
312
312
|
collectParsedAction(parsed, flags, validTypes, actions, strippedUnrecognizedTypes);
|
|
313
313
|
}
|
|
314
314
|
catch {
|
|
315
|
+
parseFailuresRef.count++;
|
|
315
316
|
// Malformed JSON — skip silently.
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
|
-
function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges) {
|
|
319
|
+
function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges, parseFailuresRef) {
|
|
319
320
|
let result = '';
|
|
320
321
|
let cursor = 0;
|
|
321
322
|
while (cursor < text.length) {
|
|
@@ -347,7 +348,7 @@ function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrec
|
|
|
347
348
|
cursor += trailing[0].length;
|
|
348
349
|
continue;
|
|
349
350
|
}
|
|
350
|
-
parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes);
|
|
351
|
+
parseActionJson(jsonStr, flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef);
|
|
351
352
|
// Advance past the JSON object and consume any trailing XML closing tags.
|
|
352
353
|
cursor = afterMarker + jsonStr.length;
|
|
353
354
|
const remaining = text.slice(cursor);
|
|
@@ -364,16 +365,18 @@ function stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrec
|
|
|
364
365
|
function parseWithRegexFallback(text, flags, validTypes, codeRanges) {
|
|
365
366
|
const actions = [];
|
|
366
367
|
const strippedUnrecognizedTypes = [];
|
|
368
|
+
const parseFailuresRef = { count: 0 };
|
|
367
369
|
const cleaned = text.replace(ACTION_RE, (match, json, offset) => {
|
|
368
370
|
if (isIndexInRanges(offset, codeRanges))
|
|
369
371
|
return match;
|
|
370
|
-
parseActionJson(json.trim(), flags, validTypes, actions, strippedUnrecognizedTypes);
|
|
372
|
+
parseActionJson(json.trim(), flags, validTypes, actions, strippedUnrecognizedTypes, parseFailuresRef);
|
|
371
373
|
return '';
|
|
372
374
|
});
|
|
373
375
|
return {
|
|
374
376
|
cleanText: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
|
|
375
377
|
actions,
|
|
376
378
|
strippedUnrecognizedTypes,
|
|
379
|
+
parseFailures: parseFailuresRef.count,
|
|
377
380
|
};
|
|
378
381
|
}
|
|
379
382
|
export function parseDiscordActions(text, flags) {
|
|
@@ -381,11 +384,13 @@ export function parseDiscordActions(text, flags) {
|
|
|
381
384
|
const actions = [];
|
|
382
385
|
const strippedUnrecognizedTypes = [];
|
|
383
386
|
const codeRanges = computeMarkdownCodeRanges(text);
|
|
384
|
-
const
|
|
387
|
+
const parseFailuresRef = { count: 0 };
|
|
388
|
+
const cleaned = stripActionsWithScanner(text, flags, validTypes, actions, strippedUnrecognizedTypes, codeRanges, parseFailuresRef);
|
|
385
389
|
const scanned = {
|
|
386
390
|
cleanText: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
|
|
387
391
|
actions,
|
|
388
392
|
strippedUnrecognizedTypes,
|
|
393
|
+
parseFailures: parseFailuresRef.count,
|
|
389
394
|
};
|
|
390
395
|
// Compatibility fallback: if scanner leaves markers behind or extracts nothing,
|
|
391
396
|
// run the legacy regex parser and prefer it when it captures more actions.
|
|
@@ -501,7 +506,7 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
|
|
|
501
506
|
}
|
|
502
507
|
catch (err) {
|
|
503
508
|
const msg = err instanceof Error ? err.message : String(err);
|
|
504
|
-
results.push({ ok: false, error: msg });
|
|
509
|
+
results.push({ ok: false, error: `Failed (${action.type}): ${msg}` });
|
|
505
510
|
log?.error({ err, action }, 'discord:action failed');
|
|
506
511
|
}
|
|
507
512
|
}
|
|
@@ -211,6 +211,28 @@ describe('parseDiscordActions', () => {
|
|
|
211
211
|
expect(actions).toHaveLength(0);
|
|
212
212
|
expect(strippedUnrecognizedTypes).toEqual(['forgeStatus']);
|
|
213
213
|
});
|
|
214
|
+
it('parses two same-type taskCreate actions and extracts both', () => {
|
|
215
|
+
const input = '<discord-action>{"type":"taskCreate","title":"First task"}</discord-action>' +
|
|
216
|
+
'<discord-action>{"type":"taskCreate","title":"Second task"}</discord-action>';
|
|
217
|
+
const { actions, parseFailures } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true });
|
|
218
|
+
expect(actions).toHaveLength(2);
|
|
219
|
+
expect(actions[0]).toEqual({ type: 'taskCreate', title: 'First task' });
|
|
220
|
+
expect(actions[1]).toEqual({ type: 'taskCreate', title: 'Second task' });
|
|
221
|
+
expect(parseFailures).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
it('returns parseFailures: 1 and only the valid action when second block has malformed JSON', () => {
|
|
224
|
+
const input = '<discord-action>{"type":"taskCreate","title":"Valid task"}</discord-action>' +
|
|
225
|
+
'<discord-action>{bad json}</discord-action>';
|
|
226
|
+
const { actions, parseFailures } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true });
|
|
227
|
+
expect(actions).toHaveLength(1);
|
|
228
|
+
expect(actions[0]).toEqual({ type: 'taskCreate', title: 'Valid task' });
|
|
229
|
+
expect(parseFailures).toBe(1);
|
|
230
|
+
});
|
|
231
|
+
it('returns parseFailures: 0 for well-formed input', () => {
|
|
232
|
+
const input = '<discord-action>{"type":"channelList"}</discord-action>';
|
|
233
|
+
const { parseFailures } = parseDiscordActions(input, ALL_FLAGS);
|
|
234
|
+
expect(parseFailures).toBe(0);
|
|
235
|
+
});
|
|
214
236
|
});
|
|
215
237
|
// ---------------------------------------------------------------------------
|
|
216
238
|
// executeDiscordActions — mocked guild
|
|
@@ -309,7 +331,7 @@ describe('executeDiscordActions', () => {
|
|
|
309
331
|
});
|
|
310
332
|
const results = await executeDiscordActions([{ type: 'channelCreate', name: 'test' }], makeCtx(guild));
|
|
311
333
|
expect(results).toHaveLength(1);
|
|
312
|
-
expect(results[0]).toEqual({ ok: false, error: 'Missing Permissions' });
|
|
334
|
+
expect(results[0]).toEqual({ ok: false, error: 'Failed (channelCreate): Missing Permissions' });
|
|
313
335
|
});
|
|
314
336
|
it('one failure does not block subsequent actions', async () => {
|
|
315
337
|
const guild = makeMockGuild([
|
|
@@ -360,6 +382,16 @@ describe('executeDiscordActions', () => {
|
|
|
360
382
|
expect(results[0].error).toContain('requires confirmation');
|
|
361
383
|
expect(results[0].error).toContain('!confirm');
|
|
362
384
|
});
|
|
385
|
+
it('executes two same-type channelCreate actions and returns two results', async () => {
|
|
386
|
+
const guild = makeMockGuild([]);
|
|
387
|
+
const results = await executeDiscordActions([
|
|
388
|
+
{ type: 'channelCreate', name: 'alpha' },
|
|
389
|
+
{ type: 'channelCreate', name: 'beta' },
|
|
390
|
+
], makeCtx(guild));
|
|
391
|
+
expect(results).toHaveLength(2);
|
|
392
|
+
expect(results[0]).toEqual({ ok: true, summary: 'Created #alpha' });
|
|
393
|
+
expect(results[1]).toEqual({ ok: true, summary: 'Created #beta' });
|
|
394
|
+
});
|
|
363
395
|
it('allows destructive actions with bypassDestructive confirmation context', async () => {
|
|
364
396
|
const ban = vi.fn(async () => { });
|
|
365
397
|
const guild = {
|
|
@@ -3,7 +3,7 @@ import { fmtTime, resolveChannel } from './action-utils.js';
|
|
|
3
3
|
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
4
4
|
import { DeferScheduler as DeferSchedulerImpl } from './defer-scheduler.js';
|
|
5
5
|
import { resolveDiscordChannelContext } from './channel-context.js';
|
|
6
|
-
import { appendUnavailableActionTypesNotice } from './output-common.js';
|
|
6
|
+
import { appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
|
|
7
7
|
import { buildContextFiles, buildPromptPreamble, inlineContextFiles, loadWorkspacePaFiles, resolveEffectiveTools, } from './prompt-common.js';
|
|
8
8
|
import { mapRuntimeErrorToUserMessage } from './user-errors.js';
|
|
9
9
|
import { resolveModel } from '../runtime/model-tiers.js';
|
|
@@ -173,6 +173,7 @@ export function configureDeferredScheduler(opts) {
|
|
|
173
173
|
outgoingText = outgoingText ? `${outgoingText}\n\n${displayLines.join('\n')}` : displayLines.join('\n');
|
|
174
174
|
}
|
|
175
175
|
outgoingText = appendUnavailableActionTypesNotice(outgoingText, parsed.strippedUnrecognizedTypes).trim();
|
|
176
|
+
outgoingText = appendParseFailureNotice(outgoingText, parsed.parseFailures).trim();
|
|
176
177
|
if (!outgoingText && runtimeError) {
|
|
177
178
|
outgoingText = runtimeError;
|
|
178
179
|
}
|
|
@@ -30,7 +30,7 @@ import { taskThreadCache } from '../tasks/thread-cache.js';
|
|
|
30
30
|
import { buildTaskContextSummary } from '../tasks/context-summary.js';
|
|
31
31
|
import { TaskStore } from '../tasks/store.js';
|
|
32
32
|
import { isChannelPublic, appendEntry, buildExcerptSummary } from './shortterm-memory.js';
|
|
33
|
-
import { editThenSendChunks, shouldSuppressFollowUp, appendUnavailableActionTypesNotice } from './output-common.js';
|
|
33
|
+
import { editThenSendChunks, shouldSuppressFollowUp, appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
|
|
34
34
|
import { downloadMessageImages, resolveMediaType } from './image-download.js';
|
|
35
35
|
import { resolveReplyReference } from './reply-reference.js';
|
|
36
36
|
import { resolveThreadContext } from './thread-context.js';
|
|
@@ -2017,6 +2017,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2017
2017
|
let actions = [];
|
|
2018
2018
|
let actionResults = [];
|
|
2019
2019
|
let strippedUnrecognizedTypes = [];
|
|
2020
|
+
let parseFailuresCount = 0;
|
|
2020
2021
|
// Gate action execution on successful stream completion — do not execute
|
|
2021
2022
|
// actions against partial or error output, which could cause side effects
|
|
2022
2023
|
// based on incomplete model responses. Relax the hadTextFinal requirement
|
|
@@ -2031,6 +2032,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2031
2032
|
&& (hadTextFinal || processedText.includes('<discord-action>'));
|
|
2032
2033
|
if (params.discordActionsEnabled && msg.guild && canParseActions) {
|
|
2033
2034
|
const parsed = parseDiscordActions(processedText, actionFlags);
|
|
2035
|
+
parseFailuresCount = parsed.parseFailures;
|
|
2036
|
+
if (parsed.parseFailures > 0) {
|
|
2037
|
+
params.log?.warn(`parseDiscordActions: ${parsed.parseFailures} action block(s) failed to parse (sessionKey=${sessionKey})`);
|
|
2038
|
+
}
|
|
2034
2039
|
if (parsed.actions.length > 0) {
|
|
2035
2040
|
actions = parsed.actions;
|
|
2036
2041
|
strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
|
|
@@ -2045,6 +2050,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2045
2050
|
mode: 'interactive',
|
|
2046
2051
|
sessionKey,
|
|
2047
2052
|
userId: msg.author.id,
|
|
2053
|
+
bypassDestructive: true,
|
|
2048
2054
|
},
|
|
2049
2055
|
};
|
|
2050
2056
|
// Construct per-message memoryCtx with real user ID and Discord metadata.
|
|
@@ -2078,7 +2084,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2078
2084
|
if (!processedText.trim()
|
|
2079
2085
|
&& anyActionSucceeded
|
|
2080
2086
|
&& collectedImages.length === 0
|
|
2081
|
-
&& strippedUnrecognizedTypes.length === 0
|
|
2087
|
+
&& strippedUnrecognizedTypes.length === 0
|
|
2088
|
+
&& parseFailuresCount === 0) {
|
|
2082
2089
|
try {
|
|
2083
2090
|
await reply.delete();
|
|
2084
2091
|
}
|
|
@@ -2103,6 +2110,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2103
2110
|
}
|
|
2104
2111
|
}
|
|
2105
2112
|
processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
|
|
2113
|
+
processedText = appendParseFailureNotice(processedText, parseFailuresCount);
|
|
2106
2114
|
// Suppression: if a follow-up response is trivially short and has no further
|
|
2107
2115
|
// actions, suppress it to avoid posting empty messages like "Got it."
|
|
2108
2116
|
// Skip suppression when images are present, or when unrecognized action blocks
|
|
@@ -147,6 +147,21 @@ export function appendUnavailableActionTypesNotice(text, strippedTypes) {
|
|
|
147
147
|
const base = String(text ?? '').trimEnd();
|
|
148
148
|
return base ? `${base}\n\n${notice}` : notice;
|
|
149
149
|
}
|
|
150
|
+
export function buildParseFailureNotice(count) {
|
|
151
|
+
if (count <= 0)
|
|
152
|
+
return '';
|
|
153
|
+
if (count === 1) {
|
|
154
|
+
return 'Warning: 1 action block failed to parse (malformed JSON) and was skipped.';
|
|
155
|
+
}
|
|
156
|
+
return `Warning: ${count} action blocks failed to parse (malformed JSON) and were skipped.`;
|
|
157
|
+
}
|
|
158
|
+
export function appendParseFailureNotice(text, count) {
|
|
159
|
+
const notice = buildParseFailureNotice(count);
|
|
160
|
+
if (!notice)
|
|
161
|
+
return text;
|
|
162
|
+
const base = String(text ?? '').trimEnd();
|
|
163
|
+
return base ? `${base}\n\n${notice}` : notice;
|
|
164
|
+
}
|
|
150
165
|
export async function sendChunks(channel, text, images) {
|
|
151
166
|
const attachments = images && images.length > 0 ? buildAttachments(images) : [];
|
|
152
167
|
const chunks = prepareDiscordOutput(text);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { buildAttachments, imageMediaTypeToExtension, editThenSendChunks, replyThenSendChunks, sendChunks, shouldSuppressFollowUp, buildUnavailableActionTypesNotice, appendUnavailableActionTypesNotice, } from './output-common.js';
|
|
2
|
+
import { buildAttachments, imageMediaTypeToExtension, editThenSendChunks, replyThenSendChunks, sendChunks, shouldSuppressFollowUp, buildUnavailableActionTypesNotice, appendUnavailableActionTypesNotice, buildParseFailureNotice, appendParseFailureNotice, } from './output-common.js';
|
|
3
3
|
describe('imageMediaTypeToExtension', () => {
|
|
4
4
|
it('maps known types', () => {
|
|
5
5
|
expect(imageMediaTypeToExtension('image/png')).toBe('png');
|
|
@@ -196,3 +196,39 @@ describe('sendChunks with images', () => {
|
|
|
196
196
|
expect(channel.send.mock.calls[0][0].files).toHaveLength(1);
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
describe('buildParseFailureNotice', () => {
|
|
200
|
+
it('returns empty string for zero failures', () => {
|
|
201
|
+
expect(buildParseFailureNotice(0)).toBe('');
|
|
202
|
+
});
|
|
203
|
+
it('returns empty string for negative count', () => {
|
|
204
|
+
expect(buildParseFailureNotice(-1)).toBe('');
|
|
205
|
+
});
|
|
206
|
+
it('returns singular warning for one failure', () => {
|
|
207
|
+
const out = buildParseFailureNotice(1);
|
|
208
|
+
expect(out).toContain('1 action block');
|
|
209
|
+
expect(out).toContain('malformed JSON');
|
|
210
|
+
expect(out).toContain('skipped');
|
|
211
|
+
});
|
|
212
|
+
it('returns plural warning for multiple failures', () => {
|
|
213
|
+
const out = buildParseFailureNotice(3);
|
|
214
|
+
expect(out).toContain('3 action blocks');
|
|
215
|
+
expect(out).toContain('malformed JSON');
|
|
216
|
+
expect(out).toContain('skipped');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('appendParseFailureNotice', () => {
|
|
220
|
+
it('appends the notice under existing text', () => {
|
|
221
|
+
const out = appendParseFailureNotice('hello', 1);
|
|
222
|
+
expect(out).toContain('hello');
|
|
223
|
+
expect(out).toContain('malformed JSON');
|
|
224
|
+
expect(out.indexOf('hello')).toBeLessThan(out.indexOf('malformed JSON'));
|
|
225
|
+
});
|
|
226
|
+
it('returns notice alone when base text is empty', () => {
|
|
227
|
+
const out = appendParseFailureNotice('', 2);
|
|
228
|
+
expect(out).toContain('2 action blocks');
|
|
229
|
+
expect(out.startsWith('Warning:')).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
it('returns original text when count is zero', () => {
|
|
232
|
+
expect(appendParseFailureNotice('hello', 0)).toBe('hello');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -8,7 +8,7 @@ import { tryResolveReactionPrompt } from './reaction-prompts.js';
|
|
|
8
8
|
import { tryAbortAll } from './abort-registry.js';
|
|
9
9
|
import { getActiveOrchestrator } from './forge-plan-registry.js';
|
|
10
10
|
import { buildContextFiles, inlineContextFiles, buildDurableMemorySection, buildTaskThreadSection, loadWorkspacePaFiles, resolveEffectiveTools, buildPromptPreamble } from './prompt-common.js';
|
|
11
|
-
import { editThenSendChunks, appendUnavailableActionTypesNotice } from './output-common.js';
|
|
11
|
+
import { editThenSendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from './output-common.js';
|
|
12
12
|
import { formatBoldLabel, thinkingLabel, selectStreamingOutput } from './output-utils.js';
|
|
13
13
|
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
14
14
|
import { registerInFlightReply, isShuttingDown } from './inflight-replies.js';
|
|
@@ -515,11 +515,13 @@ function createReactionHandler(mode, params, queue, statusRef) {
|
|
|
515
515
|
let parsedActions = [];
|
|
516
516
|
let actionResults = [];
|
|
517
517
|
let strippedUnrecognizedTypes = [];
|
|
518
|
+
let parseFailuresCount = 0;
|
|
518
519
|
if (params.discordActionsEnabled && msg.guild && canParseActions && !invokeError) {
|
|
519
520
|
const parsed = parseDiscordActions(processedText, actionFlags);
|
|
520
521
|
parsedActionCount = parsed.actions.length;
|
|
521
522
|
parsedActions = parsed.actions;
|
|
522
523
|
strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
|
|
524
|
+
parseFailuresCount = parsed.parseFailures;
|
|
523
525
|
if (parsed.actions.length > 0) {
|
|
524
526
|
const actCtx = {
|
|
525
527
|
guild: msg.guild,
|
|
@@ -564,7 +566,8 @@ function createReactionHandler(mode, params, queue, statusRef) {
|
|
|
564
566
|
if (!processedText.trim()
|
|
565
567
|
&& anyActionSucceeded
|
|
566
568
|
&& collectedImages.length === 0
|
|
567
|
-
&& strippedUnrecognizedTypes.length === 0
|
|
569
|
+
&& strippedUnrecognizedTypes.length === 0
|
|
570
|
+
&& parseFailuresCount === 0) {
|
|
568
571
|
try {
|
|
569
572
|
await reply?.delete?.();
|
|
570
573
|
}
|
|
@@ -587,6 +590,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
|
|
|
587
590
|
}
|
|
588
591
|
}
|
|
589
592
|
processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
|
|
593
|
+
processedText = appendParseFailureNotice(processedText, parseFailuresCount);
|
|
590
594
|
// Suppress empty responses and the HEARTBEAT_OK sentinel — delete placeholder and bail.
|
|
591
595
|
const strippedText = processedText.replace(/\s+/g, ' ').trim();
|
|
592
596
|
const isSuppressible = strippedText.length === 0 || strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
|
|
@@ -556,7 +556,7 @@ describe('destructive action confirmation flow', () => {
|
|
|
556
556
|
afterEach(() => {
|
|
557
557
|
resetDestructiveConfirm();
|
|
558
558
|
});
|
|
559
|
-
it('
|
|
559
|
+
it('executes destructive actions immediately in interactive mode without !confirm', async () => {
|
|
560
560
|
const ban = vi.fn(async () => { });
|
|
561
561
|
const guild = {
|
|
562
562
|
members: {
|
|
@@ -572,17 +572,8 @@ describe('destructive action confirmation flow', () => {
|
|
|
572
572
|
const handler = createMessageCreateHandler(baseParams(runtime, { discordActionsModeration: true }), makeQueue());
|
|
573
573
|
const msg1 = makeMsg({ guild, content: 'ban that user' });
|
|
574
574
|
await handler(msg1);
|
|
575
|
-
const firstReply = await msg1.reply.mock.results[0]?.value;
|
|
576
|
-
const firstContent = String(firstReply?.edit?.mock?.calls?.[firstReply.edit.mock.calls.length - 1]?.[0]?.content ?? '');
|
|
577
|
-
const token = /!confirm\s+([a-z0-9_-]{6,64})/i.exec(firstContent)?.[1];
|
|
578
|
-
expect(token).toBeTruthy();
|
|
579
|
-
expect(ban).not.toHaveBeenCalled();
|
|
580
|
-
const msg2 = makeMsg({ guild, content: `!confirm ${token}` });
|
|
581
|
-
await handler(msg2);
|
|
582
575
|
expect(runtime.invoke).toHaveBeenCalledTimes(1);
|
|
583
576
|
expect(ban).toHaveBeenCalledOnce();
|
|
584
|
-
const confirmReply = msg2.reply.mock.calls[0]?.[0]?.content ?? '';
|
|
585
|
-
expect(confirmReply).toContain('Confirmed `ban`.');
|
|
586
577
|
});
|
|
587
578
|
});
|
|
588
579
|
// ---------------------------------------------------------------------------
|
|
@@ -285,6 +285,27 @@ describe('closeTaskThread', () => {
|
|
|
285
285
|
await closeTaskThread(client, 'missing', task);
|
|
286
286
|
// No error thrown — function completes silently.
|
|
287
287
|
});
|
|
288
|
+
it('calls thread.edit with correct this context (regression: unbound this)', async () => {
|
|
289
|
+
let capturedThis;
|
|
290
|
+
const mockClient = { rest: {} };
|
|
291
|
+
const thread = {
|
|
292
|
+
isThread: () => true,
|
|
293
|
+
archived: false,
|
|
294
|
+
client: mockClient,
|
|
295
|
+
fetchStarterMessage: vi.fn(async () => ({
|
|
296
|
+
author: { id: 'bot-123' },
|
|
297
|
+
content: buildTaskStarterContent(task),
|
|
298
|
+
edit: vi.fn(),
|
|
299
|
+
})),
|
|
300
|
+
send: vi.fn(),
|
|
301
|
+
setArchived: vi.fn(),
|
|
302
|
+
edit: function (_payload) { capturedThis = this; },
|
|
303
|
+
};
|
|
304
|
+
const client = makeClient(thread);
|
|
305
|
+
await closeTaskThread(client, 'thread-1', task);
|
|
306
|
+
expect(capturedThis).toBe(thread);
|
|
307
|
+
expect(capturedThis.client).toBe(mockClient);
|
|
308
|
+
});
|
|
288
309
|
});
|
|
289
310
|
// ---------------------------------------------------------------------------
|
|
290
311
|
// getStatusTagIds
|
|
@@ -461,6 +482,20 @@ describe('updateTaskThreadTags', () => {
|
|
|
461
482
|
});
|
|
462
483
|
expect(thread.edit.mock.calls[0][0].appliedTags).not.toContain('s1');
|
|
463
484
|
});
|
|
485
|
+
it('calls thread.edit with correct this context (regression: unbound this)', async () => {
|
|
486
|
+
let capturedThis;
|
|
487
|
+
const mockClient = { rest: {} };
|
|
488
|
+
const tagMap = { open: 's1', in_progress: 's2' };
|
|
489
|
+
const thread = {
|
|
490
|
+
isThread: () => true,
|
|
491
|
+
appliedTags: ['s1'],
|
|
492
|
+
client: mockClient,
|
|
493
|
+
edit: function (_payload) { capturedThis = this; },
|
|
494
|
+
};
|
|
495
|
+
await updateTaskThreadTags(makeClient(thread), '123', task, tagMap);
|
|
496
|
+
expect(capturedThis).toBe(thread);
|
|
497
|
+
expect(capturedThis.client).toBe(mockClient);
|
|
498
|
+
});
|
|
464
499
|
});
|
|
465
500
|
// ---------------------------------------------------------------------------
|
|
466
501
|
// closeTaskThread with tagMap
|
|
@@ -17,7 +17,7 @@ function asEditableTaskThread(thread) {
|
|
|
17
17
|
const appliedTags = getThreadAppliedTags(thread);
|
|
18
18
|
return {
|
|
19
19
|
appliedTags: appliedTags.length > 0 ? appliedTags : undefined,
|
|
20
|
-
edit: candidate.edit,
|
|
20
|
+
edit: candidate.edit.bind(candidate),
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
/** Post a close summary, rename with checkmark, and archive the thread. */
|
|
@@ -370,7 +370,7 @@ describe('template content — AGENTS.md', () => {
|
|
|
370
370
|
});
|
|
371
371
|
it('contains task creation guidance', async () => {
|
|
372
372
|
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
373
|
-
expect(agents).toContain('Task
|
|
373
|
+
expect(agents).toContain('Task Management');
|
|
374
374
|
});
|
|
375
375
|
it('contains git commit hash guidance', async () => {
|
|
376
376
|
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
@@ -482,13 +482,13 @@ describe('scaffolded workspace contains operational content', () => {
|
|
|
482
482
|
'Landing the Plane',
|
|
483
483
|
'Plan-Audit-Implement Workflow',
|
|
484
484
|
'Discord Formatting',
|
|
485
|
-
'Task
|
|
485
|
+
'Task Management',
|
|
486
486
|
];
|
|
487
487
|
for (const section of requiredSections) {
|
|
488
488
|
expect(agents).toContain(section);
|
|
489
489
|
}
|
|
490
490
|
});
|
|
491
|
-
it('fresh scaffold produces TOOLS.md with all
|
|
491
|
+
it('fresh scaffold produces TOOLS.md with all 30 action types', async () => {
|
|
492
492
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-content-'));
|
|
493
493
|
dirs.push(workspace);
|
|
494
494
|
await ensureWorkspaceBootstrapFiles(workspace);
|
|
@@ -497,6 +497,8 @@ describe('scaffolded workspace contains operational content', () => {
|
|
|
497
497
|
'forgeCreate', 'forgeResume', 'forgeStatus', 'forgeCancel',
|
|
498
498
|
'planList', 'planShow', 'planApprove', 'planClose', 'planCreate', 'planRun',
|
|
499
499
|
'memoryRemember', 'memoryForget', 'memoryShow',
|
|
500
|
+
'taskCreate', 'taskUpdate', 'taskClose', 'taskShow', 'taskList', 'taskSync', 'tagMapReload',
|
|
501
|
+
'cronCreate', 'cronUpdate', 'cronList', 'cronShow', 'cronPause', 'cronResume', 'cronDelete', 'cronTrigger', 'cronSync', 'cronTagMapReload',
|
|
500
502
|
];
|
|
501
503
|
for (const action of allActionTypes) {
|
|
502
504
|
expect(tools).toContain(action);
|
package/package.json
CHANGED
|
@@ -141,13 +141,13 @@ Plans are stored in `workspace/plans/plan-NNN-slug.md`. The user must explicitly
|
|
|
141
141
|
|
|
142
142
|
**Canonical reference:** See `docs/plan-and-forge.md` for full command syntax, the forge orchestration loop, phase manager details, configuration options, and end-to-end workflows.
|
|
143
143
|
|
|
144
|
-
##
|
|
144
|
+
## Discord Action Types
|
|
145
145
|
|
|
146
|
-
See TOOLS.md for the full reference of forge, plan, and
|
|
146
|
+
See TOOLS.md for the full reference of forge, plan, memory, task, and cron `<discord-action>` types. Never send `!forge`/`!plan`/`!memory` as text messages — bot-sent messages don't trigger command handlers. Use the action blocks instead.
|
|
147
147
|
|
|
148
|
-
## Task
|
|
148
|
+
## Task Management
|
|
149
149
|
|
|
150
|
-
After creating a task, always post a link to its Discord thread so the user can jump straight to it.
|
|
150
|
+
Discoclaw has a built-in task tracker backed by Discord forum threads. Use `taskCreate` for tracking work items (TODOs, follow-ups, bugs, feature requests) — not GitHub issues, not manual thread creation. After creating a task, always post a link to its Discord thread so the user can jump straight to it. See TOOLS.md for action syntax.
|
|
151
151
|
|
|
152
152
|
## Discord Action Batching
|
|
153
153
|
|
|
@@ -196,9 +196,9 @@ This is a starting point. Add your own conventions, style, and rules as you figu
|
|
|
196
196
|
|
|
197
197
|
**MANDATORY WORKFLOW:**
|
|
198
198
|
|
|
199
|
-
1. **
|
|
199
|
+
1. **Track remaining work** - Use `taskCreate` for anything that needs follow-up
|
|
200
200
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
|
201
|
-
3. **Update
|
|
201
|
+
3. **Update task status** - Use `taskClose`/`taskUpdate` to close finished work and update in-progress items
|
|
202
202
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
|
203
203
|
```bash
|
|
204
204
|
git pull --rebase
|
|
@@ -177,7 +177,19 @@ Plans are stored in `workspace/plans/plan-NNN-slug.md`. Complex plans can be dec
|
|
|
177
177
|
|
|
178
178
|
**Canonical reference:** See `docs/plan-and-forge.md` for full command syntax, the forge orchestration loop, phase manager details, configuration options, and end-to-end workflows.
|
|
179
179
|
|
|
180
|
-
##
|
|
180
|
+
## Task Management
|
|
181
|
+
|
|
182
|
+
Discoclaw has a built-in task tracker backed by Discord forum threads. Use `taskCreate` for tracking work items — not GitHub issues and not manual thread creation.
|
|
183
|
+
|
|
184
|
+
**When to create a task:**
|
|
185
|
+
- TODOs or action items that come up in conversation
|
|
186
|
+
- Follow-up work the user mentions but isn't ready to start
|
|
187
|
+
- Bug reports, feature requests, or things to revisit later
|
|
188
|
+
- Any work item the user wants tracked
|
|
189
|
+
|
|
190
|
+
After creating a task, always post a link to its Discord thread so the user can jump straight to it.
|
|
191
|
+
|
|
192
|
+
## Discord Action Types
|
|
181
193
|
|
|
182
194
|
Use these as `<discord-action>` blocks in responses — never send `!forge`/`!plan`/`!memory` as text messages (bot-sent messages don't trigger command handlers).
|
|
183
195
|
|
|
@@ -270,6 +282,122 @@ Use planList to check existing plans before creating duplicates. Use forgeCreate
|
|
|
270
282
|
|
|
271
283
|
Use memoryRemember to proactively store important facts (preferences, projects, tools, constraints). Pick the most specific `kind` that fits. Memory items persist across sessions, channels, and restarts.
|
|
272
284
|
|
|
285
|
+
### Task Actions
|
|
286
|
+
|
|
287
|
+
**taskCreate** — Create a new task:
|
|
288
|
+
```
|
|
289
|
+
<discord-action>{"type":"taskCreate","title":"Task title","description":"Optional details","priority":2,"tags":"feature,work"}</discord-action>
|
|
290
|
+
```
|
|
291
|
+
- `title` (required): Task title.
|
|
292
|
+
- `description` (optional): Detailed description.
|
|
293
|
+
- `priority` (optional): 0-4 (0=highest, default 2).
|
|
294
|
+
- `tags` (optional): Comma-separated labels/tags.
|
|
295
|
+
|
|
296
|
+
**taskUpdate** — Update a task's fields:
|
|
297
|
+
```
|
|
298
|
+
<discord-action>{"type":"taskUpdate","taskId":"ws-001","status":"in_progress","priority":1}</discord-action>
|
|
299
|
+
```
|
|
300
|
+
- `taskId` (required): Task ID.
|
|
301
|
+
- `title`, `description`, `priority`, `status` (optional): Fields to update.
|
|
302
|
+
|
|
303
|
+
**taskClose** — Close a task:
|
|
304
|
+
```
|
|
305
|
+
<discord-action>{"type":"taskClose","taskId":"ws-001","reason":"Done"}</discord-action>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**taskShow** — Show task details:
|
|
309
|
+
```
|
|
310
|
+
<discord-action>{"type":"taskShow","taskId":"ws-001"}</discord-action>
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**taskList** — List tasks:
|
|
314
|
+
```
|
|
315
|
+
<discord-action>{"type":"taskList","status":"open","limit":10}</discord-action>
|
|
316
|
+
```
|
|
317
|
+
- `status` (optional): Filter by status (open, in_progress, blocked, closed, all).
|
|
318
|
+
- `label` (optional): Filter by label.
|
|
319
|
+
- `limit` (optional): Max results.
|
|
320
|
+
|
|
321
|
+
**taskSync** — Run full sync between local task store and Discord threads:
|
|
322
|
+
```
|
|
323
|
+
<discord-action>{"type":"taskSync"}</discord-action>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**tagMapReload** — Reload tag map from disk (hot-reload without restart):
|
|
327
|
+
```
|
|
328
|
+
<discord-action>{"type":"tagMapReload"}</discord-action>
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### Task Quality Guidelines
|
|
332
|
+
- **Title**: imperative mood, specific, <60 chars. Good: "Add retry logic to webhook handler", "Plan March Denver trip". Bad: "fix stuff".
|
|
333
|
+
- **Description** should answer what/why/scope. Use markdown for structure. Include what "done" looks like for larger tasks. Max 1900 characters — the system will reject longer descriptions with an error.
|
|
334
|
+
- **Priority**: P0=urgent, P1=important, P2=normal (default), P3=nice-to-have, P4=someday.
|
|
335
|
+
- If the user explicitly asks to create a task, always create it.
|
|
336
|
+
- Apply the same description quality standards when using taskUpdate to backfill details.
|
|
337
|
+
|
|
338
|
+
Use taskList to check existing tasks before creating duplicates. Use taskShow/taskUpdate/taskClose to interact with existing tasks by ID rather than channel-name messaging.
|
|
339
|
+
|
|
340
|
+
### Cron Actions (Automations)
|
|
341
|
+
|
|
342
|
+
**Automations** is the user-facing name for crons. Each automation lives as a thread in a dedicated Discord forum channel (typically called "automations"). When a user says "create an automation," "set up a scheduled task," or "run X every morning/weekly/etc.," respond with `cronCreate`. Use `cronList` to check what's already running before creating a new one.
|
|
343
|
+
|
|
344
|
+
**cronCreate** — Create a new scheduled task:
|
|
345
|
+
```
|
|
346
|
+
<discord-action>{"type":"cronCreate","name":"Morning Report","schedule":"0 7 * * 1-5","timezone":"America/Los_Angeles","channel":"general","prompt":"Generate a brief morning status update","model":"fast"}</discord-action>
|
|
347
|
+
```
|
|
348
|
+
- `name` (required): Human-readable name.
|
|
349
|
+
- `schedule` (required): 5-field cron expression (e.g., "0 7 * * 1-5").
|
|
350
|
+
- `channel` (required): Target channel name or ID.
|
|
351
|
+
- `prompt` (required): The instruction text.
|
|
352
|
+
- `timezone` (optional, default: system timezone, or DEFAULT_TIMEZONE env if set): IANA timezone.
|
|
353
|
+
- `tags` (optional): Comma-separated purpose tags.
|
|
354
|
+
- `model` (optional): "fast" or "capable" (auto-classified if omitted).
|
|
355
|
+
|
|
356
|
+
**cronUpdate** — Update a cron's settings:
|
|
357
|
+
```
|
|
358
|
+
<discord-action>{"type":"cronUpdate","cronId":"cron-a1b2c3d4","schedule":"0 9 * * *","model":"capable"}</discord-action>
|
|
359
|
+
```
|
|
360
|
+
- `cronId` (required): The stable cron ID.
|
|
361
|
+
- `schedule`, `timezone`, `channel`, `prompt`, `model`, `tags` (optional).
|
|
362
|
+
|
|
363
|
+
**cronList** — List all cron jobs:
|
|
364
|
+
```
|
|
365
|
+
<discord-action>{"type":"cronList"}</discord-action>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**cronShow** — Show full details for a cron:
|
|
369
|
+
```
|
|
370
|
+
<discord-action>{"type":"cronShow","cronId":"cron-a1b2c3d4"}</discord-action>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
**cronPause** / **cronResume** — Pause or resume a cron:
|
|
374
|
+
```
|
|
375
|
+
<discord-action>{"type":"cronPause","cronId":"cron-a1b2c3d4"}</discord-action>
|
|
376
|
+
<discord-action>{"type":"cronResume","cronId":"cron-a1b2c3d4"}</discord-action>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**cronDelete** — Remove a cron job and archive its thread:
|
|
380
|
+
```
|
|
381
|
+
<discord-action>{"type":"cronDelete","cronId":"cron-a1b2c3d4"}</discord-action>
|
|
382
|
+
```
|
|
383
|
+
Note: cronDelete **archives** the thread (reversible) — it does not permanently delete it. The thread history is preserved and can be unarchived later via the Discord UI, which will re-register the cron job automatically.
|
|
384
|
+
|
|
385
|
+
**cronTrigger** — Immediately execute a cron (manual fire):
|
|
386
|
+
```
|
|
387
|
+
<discord-action>{"type":"cronTrigger","cronId":"cron-a1b2c3d4"}</discord-action>
|
|
388
|
+
```
|
|
389
|
+
Note: `force` overrides are disabled in Discord actions.
|
|
390
|
+
|
|
391
|
+
**cronSync** — Run full bidirectional sync:
|
|
392
|
+
```
|
|
393
|
+
<discord-action>{"type":"cronSync"}</discord-action>
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**cronTagMapReload** — Reload tag map from disk and optionally trigger sync:
|
|
397
|
+
```
|
|
398
|
+
<discord-action>{"type":"cronTagMapReload"}</discord-action>
|
|
399
|
+
```
|
|
400
|
+
|
|
273
401
|
### Model Configuration
|
|
274
402
|
|
|
275
403
|
**modelShow** — Show current model assignments for all roles:
|