taskode 0.4.0 → 0.4.2

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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/cli.js +51 -80
  3. package/src/init.js +351 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskode",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Taskode - Symphony-aligned Node orchestrator for AI issue execution",
5
5
  "type": "module",
6
6
  "files": [
@@ -14,7 +14,7 @@
14
14
  "taskode": "bin/taskode.js"
15
15
  },
16
16
  "scripts": {
17
- "start": "node ./bin/taskode.js ./WORKFLOW.md",
17
+ "start": "node ./bin/taskode.js",
18
18
  "test": "npm run test:unit && npm run test:integration && npm run test:e2e",
19
19
  "test:unit": "node --test test/*.test.js",
20
20
  "test:integration": "node test/integration/orchestrator-flow.test.js && node test/integration/recovery-and-cleanup.test.js",
package/src/cli.js CHANGED
@@ -3,16 +3,28 @@ import path from 'node:path';
3
3
  import { TaskodeStore } from './store.js';
4
4
  import { createServer } from './server.js';
5
5
  import { WorkflowStore } from './workflow-store.js';
6
+ import { ensureProjectWorkflow, runInitCommand } from './init.js';
6
7
  import { createTracker } from './tracker/index.js';
7
8
  import { WorkspaceManager } from './workspace.js';
8
9
  import { AgentRunner } from './runner.js';
9
10
  import { Orchestrator } from './orchestrator.js';
10
11
 
11
12
  export async function runCli(args) {
12
- const command = args[0] || 'start';
13
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
14
+ runHelp();
15
+ return;
16
+ }
17
+
18
+ const command = ['init', 'doctor'].includes(args[0]) ? args[0] : 'start';
13
19
 
14
20
  if (command === 'init') {
15
- runInit(process.cwd());
21
+ const options = parseOptions(args.slice(1));
22
+ await runInitCommand({
23
+ cwd: process.cwd(),
24
+ initMode: options.initMode,
25
+ input: process.stdin,
26
+ output: process.stdout
27
+ });
16
28
  return;
17
29
  }
18
30
 
@@ -22,7 +34,13 @@ export async function runCli(args) {
22
34
  }
23
35
 
24
36
  const options = parseOptions(args);
25
- const workflowPath = path.resolve(process.cwd(), options.workflowPath || 'WORKFLOW.md');
37
+ const { workflowPath } = await ensureProjectWorkflow({
38
+ cwd: process.cwd(),
39
+ explicitPath: options.workflowPath,
40
+ initMode: options.initMode,
41
+ input: process.stdin,
42
+ output: process.stdout
43
+ });
26
44
  const workflowRuntime = new WorkflowStore({ workflowPath, cliOptions: options });
27
45
  const config = workflowRuntime.getConfig();
28
46
 
@@ -65,82 +83,6 @@ function ensureSeedTaskIfEmpty(store, config) {
65
83
  });
66
84
  }
67
85
 
68
- function runInit(cwd) {
69
- const workflowPath = path.join(cwd, 'WORKFLOW.md');
70
- if (fs.existsSync(workflowPath)) {
71
- console.log('[taskode] WORKFLOW.md already exists');
72
- return;
73
- }
74
-
75
- fs.writeFileSync(
76
- workflowPath,
77
- [
78
- '---',
79
- 'tracker:',
80
- ' kind: local',
81
- 'workspace:',
82
- ' root: ./.taskode/workspaces',
83
- ' seed_from_project: true',
84
- ' copy_ignore: [".git", ".taskode", "node_modules"]',
85
- 'agent:',
86
- ' poll_interval_ms: 3000',
87
- ' max_concurrent_agents: 2',
88
- ' max_retry_attempts: 5',
89
- ' max_retry_backoff_ms: 60000',
90
- ' max_concurrent_agents_by_state: {}',
91
- ' max_turns: 20',
92
- 'worker:',
93
- ' ssh_hosts: []',
94
- ' max_concurrent_agents_per_host:',
95
- 'codex:',
96
- ' mode: shell',
97
- ' command: echo "simulated codex run"',
98
- ' timeout_ms: 120000',
99
- ' read_timeout_ms: 5000',
100
- ' turn_timeout_ms: 3600000',
101
- ' stall_timeout_ms: 300000',
102
- ' approval_policy:',
103
- ' reject:',
104
- ' sandbox_approval: true',
105
- ' rules: true',
106
- ' mcp_elicitations: true',
107
- ' thread_sandbox: workspace-write',
108
- 'hooks:',
109
- ' after_create:',
110
- ' before_run:',
111
- ' after_run:',
112
- ' before_remove:',
113
- ' timeout_ms: 60000',
114
- 'policy:',
115
- ' allow_commands: ["*"]',
116
- ' deny_commands: []',
117
- 'review:',
118
- ' auto_cleanup_approved: true',
119
- 'auth:',
120
- ' token: $TASKODE_API_TOKEN',
121
- 'server:',
122
- ' host: 127.0.0.1',
123
- ' port: 4317',
124
- '---',
125
- '',
126
- 'You are working on issue {{ issue.identifier }}.',
127
- '',
128
- 'Title: {{ issue.title }}',
129
- 'Body: {{ issue.description }}',
130
- '',
131
- 'Deliverables:',
132
- '1) Plan',
133
- '2) Implementation',
134
- '3) Tests',
135
- '4) Summary',
136
- ''
137
- ].join('\n'),
138
- 'utf8'
139
- );
140
-
141
- console.log('[taskode] created WORKFLOW.md');
142
- }
143
-
144
86
  function runDoctor() {
145
87
  console.log(`[taskode] node: ${process.version}`);
146
88
  console.log(`[taskode] LINEAR_API_KEY: ${process.env.LINEAR_API_KEY ? 'set' : 'not set'}`);
@@ -150,11 +92,17 @@ function runDoctor() {
150
92
  }
151
93
 
152
94
  function parseOptions(args) {
153
- const options = { workflowPath: null, port: null, logsRoot: null };
95
+ const options = { workflowPath: null, port: null, logsRoot: null, initMode: 'prompt' };
154
96
 
155
97
  for (let index = 0; index < args.length; index += 1) {
156
98
  const value = args[index];
157
99
 
100
+ if (value === '--workflow') {
101
+ options.workflowPath = args[index + 1];
102
+ index += 1;
103
+ continue;
104
+ }
105
+
158
106
  if (value === '--port') {
159
107
  options.port = Number(args[index + 1]);
160
108
  index += 1;
@@ -167,6 +115,16 @@ function parseOptions(args) {
167
115
  continue;
168
116
  }
169
117
 
118
+ if (value === '--default') {
119
+ options.initMode = 'default';
120
+ continue;
121
+ }
122
+
123
+ if (value === '--custom') {
124
+ options.initMode = 'custom';
125
+ continue;
126
+ }
127
+
170
128
  if (!value.startsWith('-') && value !== 'start' && !options.workflowPath) {
171
129
  options.workflowPath = value;
172
130
  }
@@ -174,3 +132,16 @@ function parseOptions(args) {
174
132
 
175
133
  return options;
176
134
  }
135
+
136
+ function runHelp() {
137
+ console.log('Usage:');
138
+ console.log(' taskode');
139
+ console.log(' taskode start [--workflow <path>] [--port <port>] [--logs-root <path>]');
140
+ console.log(' taskode init [--default|--custom]');
141
+ console.log(' taskode doctor');
142
+ console.log('');
143
+ console.log('Behavior:');
144
+ console.log(' - prefers .taskode/WORKFLOW.md');
145
+ console.log(' - falls back to legacy WORKFLOW.md');
146
+ console.log(' - initializes .taskode/WORKFLOW.md on first run when missing');
147
+ }
package/src/init.js ADDED
@@ -0,0 +1,351 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
5
+
6
+ export const TASKODE_DIRNAME = '.taskode';
7
+ export const DEFAULT_WORKFLOW_FILENAME = 'WORKFLOW.md';
8
+ export const DEFAULT_WORKFLOW_RELATIVE_PATH = path.join(TASKODE_DIRNAME, DEFAULT_WORKFLOW_FILENAME);
9
+ const LEGACY_WORKFLOW_FILENAME = 'WORKFLOW.md';
10
+ const DEFAULT_COPY_IGNORE = ['.git', '.taskode', 'node_modules'];
11
+
12
+ export function findWorkflowPath(cwd, explicitPath = null) {
13
+ if (explicitPath) {
14
+ const resolvedPath = path.resolve(cwd, explicitPath);
15
+ return fs.existsSync(resolvedPath) ? resolvedPath : null;
16
+ }
17
+
18
+ const preferredPath = path.resolve(cwd, DEFAULT_WORKFLOW_RELATIVE_PATH);
19
+ if (fs.existsSync(preferredPath)) return preferredPath;
20
+
21
+ const legacyPath = path.resolve(cwd, LEGACY_WORKFLOW_FILENAME);
22
+ return fs.existsSync(legacyPath) ? legacyPath : null;
23
+ }
24
+
25
+ export async function ensureProjectWorkflow({
26
+ cwd,
27
+ explicitPath = null,
28
+ initMode = 'prompt',
29
+ input = defaultInput,
30
+ output = defaultOutput
31
+ }) {
32
+ const existingPath = findWorkflowPath(cwd, explicitPath);
33
+ if (existingPath) {
34
+ return { workflowPath: existingPath, created: false, createdWith: null };
35
+ }
36
+
37
+ if (explicitPath) {
38
+ throw new Error(`WORKFLOW file not found: ${path.resolve(cwd, explicitPath)}`);
39
+ }
40
+
41
+ const workflowPath = path.resolve(cwd, DEFAULT_WORKFLOW_RELATIVE_PATH);
42
+ const createdWith = await resolveInitMode({ initMode, input, output });
43
+ const profile = createdWith === 'custom'
44
+ ? await promptCustomProfile({ input, output })
45
+ : defaultInitProfile();
46
+
47
+ writeWorkflowFiles({ cwd, workflowPath, profile });
48
+
49
+ output.write(`[taskode] initialized ${path.relative(cwd, workflowPath) || DEFAULT_WORKFLOW_RELATIVE_PATH}\n`);
50
+ output.write('[taskode] config lives in .taskode/, runtime data will stay under the same folder\n');
51
+
52
+ return { workflowPath, created: true, createdWith };
53
+ }
54
+
55
+ export async function runInitCommand({
56
+ cwd,
57
+ initMode = 'prompt',
58
+ input = defaultInput,
59
+ output = defaultOutput
60
+ }) {
61
+ const existingPath = findWorkflowPath(cwd);
62
+ if (existingPath) {
63
+ output.write(`[taskode] workflow already exists: ${path.relative(cwd, existingPath) || existingPath}\n`);
64
+ return { workflowPath: existingPath, created: false, createdWith: null };
65
+ }
66
+
67
+ return ensureProjectWorkflow({ cwd, initMode, input, output });
68
+ }
69
+
70
+ export function buildWorkflowDocument(profileInput = {}) {
71
+ const profile = { ...defaultInitProfile(), ...profileInput };
72
+ const lines = [
73
+ '---',
74
+ 'tracker:',
75
+ ` kind: ${profile.trackerKind}`
76
+ ];
77
+
78
+ if (profile.trackerKind === 'linear') {
79
+ lines.push(` api_key: $${profile.linearApiEnv}`);
80
+ lines.push(` project_slug: ${yamlString(profile.linearProjectSlug)}`);
81
+ }
82
+
83
+ if (profile.trackerKind === 'github') {
84
+ lines.push(` github_token: $${profile.githubTokenEnv}`);
85
+ lines.push(` github_owner: ${yamlString(profile.githubOwner)}`);
86
+ lines.push(` github_repo: ${yamlString(profile.githubRepo)}`);
87
+ lines.push(` github_labels: ${yamlArray(profile.githubLabels)}`);
88
+ }
89
+
90
+ lines.push(
91
+ 'workspace:',
92
+ ' root: ./.taskode/workspaces',
93
+ ' seed_from_project: true',
94
+ ` copy_ignore: ${yamlArray(DEFAULT_COPY_IGNORE)}`,
95
+ 'agent:',
96
+ ` poll_interval_ms: ${profile.pollIntervalMs}`,
97
+ ` max_concurrent_agents: ${profile.maxConcurrentAgents}`,
98
+ ` max_retry_attempts: ${profile.maxRetryAttempts}`,
99
+ ` max_retry_backoff_ms: ${profile.maxRetryBackoffMs}`,
100
+ ' max_concurrent_agents_by_state: {}',
101
+ ` max_turns: ${profile.maxTurns}`,
102
+ 'worker:',
103
+ ' ssh_hosts: []',
104
+ ' max_concurrent_agents_per_host:',
105
+ 'codex:',
106
+ ` mode: ${profile.codexMode}`,
107
+ ` command: ${yamlString(profile.codexCommand)}`,
108
+ ` timeout_ms: ${profile.timeoutMs}`,
109
+ ` read_timeout_ms: ${profile.readTimeoutMs}`,
110
+ ` turn_timeout_ms: ${profile.turnTimeoutMs}`,
111
+ ` stall_timeout_ms: ${profile.stallTimeoutMs}`,
112
+ ' approval_policy:',
113
+ ' reject:',
114
+ ' sandbox_approval: true',
115
+ ' rules: true',
116
+ ' mcp_elicitations: true',
117
+ ' thread_sandbox: workspace-write',
118
+ 'hooks:',
119
+ ' after_create:',
120
+ ' before_run:',
121
+ ' after_run:',
122
+ ' before_remove:',
123
+ ' timeout_ms: 60000',
124
+ 'policy:',
125
+ ' allow_commands: ["*"]',
126
+ ' deny_commands: []',
127
+ 'review:',
128
+ ` auto_cleanup_approved: ${profile.autoCleanupApproved ? 'true' : 'false'}`,
129
+ 'auth:',
130
+ ` token: ${profile.authTokenEnv ? `$${profile.authTokenEnv}` : ''}`,
131
+ 'server:',
132
+ ' host: 127.0.0.1',
133
+ ` port: ${profile.serverPort}`,
134
+ '---',
135
+ '',
136
+ 'You are working on issue {{ issue.identifier }}.',
137
+ '',
138
+ 'Title: {{ issue.title }}',
139
+ 'Body: {{ issue.description }}',
140
+ '',
141
+ 'Deliverables:',
142
+ '1) Plan',
143
+ '2) Implementation',
144
+ '3) Tests',
145
+ '4) Summary',
146
+ ''
147
+ );
148
+
149
+ return lines.join('\n');
150
+ }
151
+
152
+ function writeWorkflowFiles({ cwd, workflowPath, profile }) {
153
+ const taskodeDir = path.join(cwd, TASKODE_DIRNAME);
154
+ fs.mkdirSync(taskodeDir, { recursive: true });
155
+ fs.writeFileSync(workflowPath, buildWorkflowDocument(profile), 'utf8');
156
+
157
+ const gitignorePath = path.join(taskodeDir, '.gitignore');
158
+ if (!fs.existsSync(gitignorePath)) {
159
+ fs.writeFileSync(
160
+ gitignorePath,
161
+ [
162
+ 'data.json',
163
+ 'logs/',
164
+ 'workspaces/'
165
+ ].join('\n') + '\n',
166
+ 'utf8'
167
+ );
168
+ }
169
+ }
170
+
171
+ async function resolveInitMode({ initMode, input, output }) {
172
+ if (initMode === 'default' || initMode === 'custom') return initMode;
173
+ if (!canPrompt(input, output)) {
174
+ output.write(`[taskode] no workflow config found, creating ${DEFAULT_WORKFLOW_RELATIVE_PATH} with defaults\n`);
175
+ return 'default';
176
+ }
177
+
178
+ const rl = readline.createInterface({ input, output });
179
+ try {
180
+ output.write('[taskode] no workflow config found for this project\n');
181
+ return askChoice(rl, {
182
+ title: 'Initialize Taskode',
183
+ choices: [
184
+ { value: 'default', label: 'Default', description: 'Create a ready-to-run local setup' },
185
+ { value: 'custom', label: 'Custom', description: 'Interactively choose tracker and runtime settings' }
186
+ ],
187
+ defaultValue: 'default'
188
+ });
189
+ } finally {
190
+ rl.close();
191
+ }
192
+ }
193
+
194
+ async function promptCustomProfile({ input, output }) {
195
+ if (!canPrompt(input, output)) {
196
+ throw new Error('interactive initialization requires a TTY');
197
+ }
198
+
199
+ const rl = readline.createInterface({ input, output });
200
+
201
+ try {
202
+ const trackerKind = await askChoice(rl, {
203
+ title: 'Tracker',
204
+ choices: [
205
+ { value: 'local', label: 'Local', description: 'Use Taskode web board as the source of truth' },
206
+ { value: 'linear', label: 'Linear', description: 'Sync tasks from Linear' },
207
+ { value: 'github', label: 'GitHub', description: 'Sync tasks from GitHub Issues' }
208
+ ],
209
+ defaultValue: 'local'
210
+ });
211
+
212
+ const codexMode = await askChoice(rl, {
213
+ title: 'Execution Mode',
214
+ choices: [
215
+ { value: 'shell', label: 'Shell', description: 'Run a single shell command per task' },
216
+ { value: 'app-server', label: 'App Server', description: 'Keep a JSON-RPC app-server session per run' }
217
+ ],
218
+ defaultValue: 'shell'
219
+ });
220
+
221
+ const profile = defaultInitProfile({
222
+ trackerKind,
223
+ codexMode
224
+ });
225
+
226
+ if (trackerKind === 'linear') {
227
+ profile.linearProjectSlug = await askRequired(rl, 'Linear project slug', 'ENG');
228
+ profile.linearApiEnv = await askText(rl, 'Linear API key env var', 'LINEAR_API_KEY');
229
+ }
230
+
231
+ if (trackerKind === 'github') {
232
+ profile.githubOwner = await askRequired(rl, 'GitHub owner', 'acme');
233
+ profile.githubRepo = await askRequired(rl, 'GitHub repo', 'project');
234
+ profile.githubTokenEnv = await askText(rl, 'GitHub token env var', 'GITHUB_TOKEN');
235
+ profile.githubLabels = parseList(await askText(rl, 'GitHub labels (comma separated)', 'taskode, ready'));
236
+ }
237
+
238
+ profile.codexCommand = await askRequired(
239
+ rl,
240
+ codexMode === 'app-server' ? 'App-server launch command' : 'Shell command',
241
+ codexMode === 'app-server' ? 'codex app-server' : 'echo "simulated codex run"'
242
+ );
243
+ profile.serverPort = await askInteger(rl, 'Web port', 4317);
244
+ profile.authTokenEnv = await askText(rl, 'API token env var (blank disables auth)', 'TASKODE_API_TOKEN');
245
+ profile.autoCleanupApproved = await askBoolean(rl, 'Auto-clean approved workspaces?', true);
246
+
247
+ return profile;
248
+ } finally {
249
+ rl.close();
250
+ }
251
+ }
252
+
253
+ function canPrompt(input, output) {
254
+ return Boolean(input?.isTTY && output?.isTTY);
255
+ }
256
+
257
+ function defaultInitProfile(overrides = {}) {
258
+ return {
259
+ trackerKind: 'local',
260
+ linearProjectSlug: 'ENG',
261
+ linearApiEnv: 'LINEAR_API_KEY',
262
+ githubOwner: 'acme',
263
+ githubRepo: 'project',
264
+ githubLabels: ['taskode', 'ready'],
265
+ githubTokenEnv: 'GITHUB_TOKEN',
266
+ pollIntervalMs: 3000,
267
+ maxConcurrentAgents: 2,
268
+ maxRetryAttempts: 5,
269
+ maxRetryBackoffMs: 60000,
270
+ maxTurns: 20,
271
+ codexMode: 'shell',
272
+ codexCommand: 'echo "simulated codex run"',
273
+ timeoutMs: 120000,
274
+ readTimeoutMs: 5000,
275
+ turnTimeoutMs: 3600000,
276
+ stallTimeoutMs: 300000,
277
+ autoCleanupApproved: true,
278
+ authTokenEnv: 'TASKODE_API_TOKEN',
279
+ serverPort: 4317,
280
+ ...overrides
281
+ };
282
+ }
283
+
284
+ async function askChoice(rl, { title, choices, defaultValue }) {
285
+ const defaultIndex = Math.max(0, choices.findIndex((choice) => choice.value === defaultValue));
286
+
287
+ while (true) {
288
+ rl.output.write(`${title}\n`);
289
+ choices.forEach((choice, index) => {
290
+ rl.output.write(` ${index + 1}. ${choice.label} - ${choice.description}\n`);
291
+ });
292
+
293
+ const answer = (await rl.question(`Select [${defaultIndex + 1}]: `)).trim();
294
+ if (!answer) return choices[defaultIndex].value;
295
+
296
+ const choice = choices[Number(answer) - 1];
297
+ if (choice) return choice.value;
298
+
299
+ rl.output.write('Please enter one of the listed numbers.\n');
300
+ }
301
+ }
302
+
303
+ async function askText(rl, label, fallback = '') {
304
+ const prompt = fallback ? `${label} [${fallback}]: ` : `${label}: `;
305
+ const answer = (await rl.question(prompt)).trim();
306
+ return answer || fallback;
307
+ }
308
+
309
+ async function askRequired(rl, label, fallback = '') {
310
+ while (true) {
311
+ const value = await askText(rl, label, fallback);
312
+ if (value.trim()) return value.trim();
313
+ rl.output.write('This value is required.\n');
314
+ }
315
+ }
316
+
317
+ async function askInteger(rl, label, fallback) {
318
+ while (true) {
319
+ const value = await askText(rl, label, String(fallback));
320
+ const parsed = Number(value);
321
+ if (Number.isInteger(parsed) && parsed >= 0) return parsed;
322
+ rl.output.write('Please enter a non-negative integer.\n');
323
+ }
324
+ }
325
+
326
+ async function askBoolean(rl, label, fallback) {
327
+ const fallbackLabel = fallback ? 'Y/n' : 'y/N';
328
+
329
+ while (true) {
330
+ const answer = (await rl.question(`${label} [${fallbackLabel}]: `)).trim().toLowerCase();
331
+ if (!answer) return fallback;
332
+ if (['y', 'yes'].includes(answer)) return true;
333
+ if (['n', 'no'].includes(answer)) return false;
334
+ rl.output.write('Please answer yes or no.\n');
335
+ }
336
+ }
337
+
338
+ function parseList(value) {
339
+ return [...new Set(String(value || '')
340
+ .split(',')
341
+ .map((entry) => entry.trim())
342
+ .filter(Boolean))];
343
+ }
344
+
345
+ function yamlArray(values) {
346
+ return `[${(values || []).map((value) => yamlString(value)).join(', ')}]`;
347
+ }
348
+
349
+ function yamlString(value) {
350
+ return JSON.stringify(String(value ?? ''));
351
+ }