team-toon-tack 1.7.1 → 2.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Wei Hung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,13 +2,18 @@
2
2
 
3
3
  [繁體中文](./README.zh-TW.md) | English
4
4
 
5
- Optimized Linear workflow for Claude Code — save 50%+ tokens compared to MCP.
5
+ Optimized Linear workflow for Claude Code — saves significant tokens compared to MCP.
6
6
 
7
7
  ## Features
8
8
 
9
- - **Token Efficient** — Local cycle cache eliminates repeated API calls, saving 50%+ tokens vs Linear MCP
9
+ - **Token Efficient** — Local cycle cache eliminates repeated API calls, saving significant tokens vs Linear MCP
10
10
  - **Smart Task Selection** — Auto-pick highest priority unassigned work with `/work-on next`
11
11
  - **Multi-team Support** — Sync and filter issues across multiple teams
12
+ - **Flexible Sync Modes** — Choose between remote (immediate Linear sync) or local (offline-first, sync later with `--update`)
13
+ - **QA/PM Team Support** — Auto-update parent issues in QA/PM team to "Testing" when completing dev tasks
14
+ - **Attachment Download** — Auto-download Linear images and files to local `.ttt/output/` for AI vision analysis
15
+ - **Blocked Status** — Set tasks as blocked when waiting on external dependencies
16
+ - **Auto Command Setup** — `ttt init` can install Claude Code commands with custom prefix
12
17
  - **Cycle History** — Local `.toon` files preserve cycle data for AI context
13
18
  - **User Filtering** — Only see issues assigned to you or unassigned
14
19
 
@@ -24,22 +29,12 @@ cd your-project
24
29
  ttt init
25
30
  ```
26
31
 
27
- ### 2. Setup Claude Code Commands
32
+ During init, you'll configure:
33
+ - **Status source**: `remote` (update Linear immediately) or `local` (work offline, sync with `ttt sync --update`)
34
+ - **QA/PM team**: For cross-team parent issue updates (parent must be set in Linear)
35
+ - **Claude Code commands**: Auto-install with optional prefix (e.g., `/ttt:work-on`)
28
36
 
29
- ```bash
30
- cp -r /path/to/team-toon-tack/templates/claude-code-commands/* .claude/commands/
31
- ```
32
-
33
- Edit `.claude/commands/work-on.md` lines 37-40 to add your project's verification steps:
34
-
35
- ```bash
36
- # Example: Add your checks here
37
- bun run typecheck
38
- bun run lint
39
- bun run test
40
- ```
41
-
42
- ### 3. Daily Workflow
37
+ ### 2. Daily Workflow
43
38
 
44
39
  In Claude Code:
45
40
 
@@ -73,6 +68,7 @@ Sync current cycle issues from Linear.
73
68
  ```bash
74
69
  ttt sync # Sync all matching issues
75
70
  ttt sync MP-123 # Sync specific issue only
71
+ ttt sync --update # Push local status changes to Linear (for local mode)
76
72
  ```
77
73
 
78
74
  ### `ttt work-on`
@@ -104,6 +100,7 @@ ttt status # Show current in-progress task
104
100
  ttt status MP-123 # Show specific issue status
105
101
  ttt status MP-123 --set +1 # Move to next status
106
102
  ttt status MP-123 --set done # Mark as done
103
+ ttt status MP-123 --set blocked # Set as blocked (waiting on dependency)
107
104
  ```
108
105
 
109
106
  ### `ttt config`
@@ -126,7 +123,8 @@ your-project/
126
123
  └── .ttt/
127
124
  ├── config.toon # Team config (gitignore recommended)
128
125
  ├── local.toon # Personal settings (gitignore)
129
- └── cycle.toon # Current cycle data (auto-generated)
126
+ ├── cycle.toon # Current cycle data (auto-generated)
127
+ └── output/ # Downloaded attachments (images, files)
130
128
  ```
131
129
 
132
130
  ### Environment Variables
package/README.zh-TW.md CHANGED
@@ -2,13 +2,18 @@
2
2
 
3
3
  繁體中文 | [English](./README.md)
4
4
 
5
- 為 Claude Code 最佳化的 Linear 工作流 — 比 MCP 節省 50% 以上的 token。
5
+ 為 Claude Code 最佳化的 Linear 工作流 — 比 MCP 節省大量 token。
6
6
 
7
7
  ## 特色功能
8
8
 
9
- - **節省 Token** — 本地 cycle 快取避免重複 API 呼叫,比 Linear MCP 50%+ token
9
+ - **節省 Token** — 本地 cycle 快取避免重複 API 呼叫,比 Linear MCP 省下大量 token
10
10
  - **智慧任務挑選** — `/work-on next` 自動選擇最高優先級的未指派工作
11
11
  - **多團隊支援** — 跨多個團隊同步與過濾 issue
12
+ - **彈性同步模式** — 選擇 remote(即時同步 Linear)或 local(離線優先,稍後用 `--update` 同步)
13
+ - **QA/PM 團隊支援** — 完成開發任務時自動將 QA/PM 團隊的 parent issue 更新為「Testing」
14
+ - **附件下載** — 自動下載 Linear 圖片和檔案到本地 `.ttt/output/`,供 AI 視覺分析
15
+ - **阻塞狀態** — 等待外部依賴時可設定任務為 blocked
16
+ - **自動安裝指令** — `ttt init` 可自動安裝 Claude Code commands,支援自訂前綴
12
17
  - **Cycle 歷史保存** — 本地 `.toon` 檔案保留 cycle 資料,方便 AI 檢閱
13
18
  - **使用者過濾** — 只顯示指派給你或未指派的工作
14
19
 
@@ -24,22 +29,12 @@ cd your-project
24
29
  ttt init
25
30
  ```
26
31
 
27
- ### 2. 設定 Claude Code Commands
32
+ 初始化時會設定:
33
+ - **狀態來源**:`remote`(即時更新 Linear)或 `local`(離線工作,用 `ttt sync --update` 同步)
34
+ - **QA/PM 團隊**:跨團隊 parent issue 更新(需在 Linear 設定 parent)
35
+ - **Claude Code commands**:自動安裝,可選前綴(如 `/ttt:work-on`)
28
36
 
29
- ```bash
30
- cp -r /path/to/team-toon-tack/templates/claude-code-commands/* .claude/commands/
31
- ```
32
-
33
- 編輯 `.claude/commands/work-on.md` 第 37-40 行,加入你專案的驗證步驟:
34
-
35
- ```bash
36
- # 範例:加入你的檢查
37
- bun run typecheck
38
- bun run lint
39
- bun run test
40
- ```
41
-
42
- ### 3. 每日工作流
37
+ ### 2. 每日工作流
43
38
 
44
39
  在 Claude Code 中:
45
40
 
@@ -73,6 +68,7 @@ ttt init --force # 覆蓋現有配置
73
68
  ```bash
74
69
  ttt sync # 同步所有符合條件的 issue
75
70
  ttt sync MP-123 # 只同步特定 issue
71
+ ttt sync --update # 將本地狀態推送到 Linear(local 模式用)
76
72
  ```
77
73
 
78
74
  ### `ttt work-on`
@@ -104,6 +100,7 @@ ttt status # 顯示當前進行中的任務
104
100
  ttt status MP-123 # 顯示特定 issue 狀態
105
101
  ttt status MP-123 --set +1 # 移動到下一狀態
106
102
  ttt status MP-123 --set done # 標記為完成
103
+ ttt status MP-123 --set blocked # 設為阻塞(等待外部依賴)
107
104
  ```
108
105
 
109
106
  ### `ttt config`
@@ -126,7 +123,8 @@ your-project/
126
123
  └── .ttt/
127
124
  ├── config.toon # 團隊配置(建議 gitignore)
128
125
  ├── local.toon # 個人設定(gitignore)
129
- └── cycle.toon # 當前 cycle 資料(自動產生)
126
+ ├── cycle.toon # 當前 cycle 資料(自動產生)
127
+ └── output/ # 下載的附件(圖片、檔案)
130
128
  ```
131
129
 
132
130
  ### 環境變數
@@ -104,8 +104,11 @@ Examples:
104
104
  });
105
105
  aiMessage = aiMsgResponse.aiMessage || "";
106
106
  }
107
- // Update Linear
108
- if (task.linearId && process.env.LINEAR_API_KEY) {
107
+ // Update Linear (only if status_source is 'remote' or not set)
108
+ const statusSource = localConfig.status_source || "remote";
109
+ if (task.linearId &&
110
+ process.env.LINEAR_API_KEY &&
111
+ statusSource === "remote") {
109
112
  const transitions = getStatusTransitions(config);
110
113
  // Update issue to Done
111
114
  const success = await updateIssueStatus(task.linearId, transitions.done, config, localConfig.team);
@@ -129,7 +132,20 @@ Examples:
129
132
  if (parentIssue) {
130
133
  const parentTeam = await parentIssue.team;
131
134
  if (parentTeam) {
132
- const parentStates = await getWorkflowStates(config, localConfig.team);
135
+ // Determine which team key to use for parent's workflow states
136
+ // If qa_pm_team is configured and matches parent's team, use it
137
+ // Otherwise, try to find the team key from config
138
+ let parentTeamKey = localConfig.team;
139
+ const teamEntries = Object.entries(config.teams);
140
+ const matchingTeam = teamEntries.find(([_, t]) => t.id === parentTeam.id);
141
+ if (matchingTeam) {
142
+ parentTeamKey = matchingTeam[0];
143
+ }
144
+ else if (localConfig.qa_pm_team) {
145
+ // Fallback to qa_pm_team if configured
146
+ parentTeamKey = localConfig.qa_pm_team;
147
+ }
148
+ const parentStates = await getWorkflowStates(config, parentTeamKey);
133
149
  const testingState = parentStates.find((s) => s.name === transitions.testing);
134
150
  if (testingState) {
135
151
  await client.updateIssue(parentIssue.id, {
@@ -145,6 +161,10 @@ Examples:
145
161
  }
146
162
  }
147
163
  }
164
+ else if (statusSource === "local") {
165
+ console.log(`Local: ${task.id} marked as completed`);
166
+ console.log(`(Linear status not updated - use 'sync --update' to push)`);
167
+ }
148
168
  // Sync full issue data from Linear (including new comment)
149
169
  const syncedTask = await syncSingleIssue(task.id, {
150
170
  config,
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import { decode, encode } from "@toon-format/toon";
4
5
  import prompts from "prompts";
5
6
  import { buildConfig, buildLocalConfig, findTeamKey, findUserKey, getDefaultStatusTransitions, } from "./lib/config-builder.js";
@@ -116,6 +117,40 @@ async function selectTeams(teams, options) {
116
117
  }
117
118
  return { selected: selectedTeams, primary: primaryTeam };
118
119
  }
120
+ async function selectQaPmTeam(teams, primaryTeam, options) {
121
+ // Only ask if there are multiple teams and interactive mode
122
+ if (!options.interactive || teams.length <= 1) {
123
+ return undefined;
124
+ }
125
+ // Filter out primary team from choices
126
+ const otherTeams = teams.filter((t) => t.id !== primaryTeam.id);
127
+ if (otherTeams.length === 0) {
128
+ return undefined;
129
+ }
130
+ console.log("\n🔗 QA/PM Team Configuration:");
131
+ const response = await prompts({
132
+ type: "select",
133
+ name: "qaPmTeamId",
134
+ message: "Select QA/PM team (for cross-team parent issue updates):",
135
+ choices: [
136
+ {
137
+ title: "(None - skip)",
138
+ value: undefined,
139
+ description: "No cross-team parent updates",
140
+ },
141
+ ...otherTeams.map((t) => ({
142
+ title: t.name,
143
+ value: t.id,
144
+ description: "Parent issues in this team will be updated to Testing",
145
+ })),
146
+ ],
147
+ initial: 0,
148
+ });
149
+ if (!response.qaPmTeamId) {
150
+ return undefined;
151
+ }
152
+ return teams.find((t) => t.id === response.qaPmTeamId);
153
+ }
119
154
  async function selectUser(users, options) {
120
155
  let currentUser = users[0];
121
156
  if (options.user) {
@@ -157,13 +192,45 @@ async function selectLabelFilter(labels, options) {
157
192
  }
158
193
  return undefined;
159
194
  }
160
- async function selectStatusMappings(states, options) {
161
- const defaults = getDefaultStatusTransitions(states);
162
- if (!options.interactive || states.length === 0) {
163
- return defaults;
195
+ async function selectStatusSource(options) {
196
+ if (!options.interactive) {
197
+ return "remote"; // default
198
+ }
199
+ console.log("\n🔄 Configure status sync mode:");
200
+ const response = await prompts({
201
+ type: "select",
202
+ name: "statusSource",
203
+ message: "Where should status updates be stored?",
204
+ choices: [
205
+ {
206
+ title: "Remote (recommended)",
207
+ value: "remote",
208
+ description: "Update Linear immediately when you work-on or complete tasks",
209
+ },
210
+ {
211
+ title: "Local",
212
+ value: "local",
213
+ description: "Work offline, then sync to Linear with 'sync --update'",
214
+ },
215
+ ],
216
+ initial: 0,
217
+ });
218
+ return response.statusSource || "remote";
219
+ }
220
+ async function selectStatusMappings(devStates, qaStates, options) {
221
+ // Use dev team states for todo, in_progress, done, blocked
222
+ // Use qa team states for testing (fallback to dev team if not set)
223
+ const devDefaults = getDefaultStatusTransitions(devStates);
224
+ const testingStates = qaStates && qaStates.length > 0 ? qaStates : devStates;
225
+ const testingDefaults = getDefaultStatusTransitions(testingStates);
226
+ if (!options.interactive || devStates.length === 0) {
227
+ return {
228
+ ...devDefaults,
229
+ testing: testingDefaults.testing,
230
+ };
164
231
  }
165
232
  console.log("\n📊 Configure status mappings:");
166
- const stateChoices = states.map((s) => ({
233
+ const devStateChoices = devStates.map((s) => ({
167
234
  title: `${s.name} (${s.type})`,
168
235
  value: s.name,
169
236
  }));
@@ -171,41 +238,63 @@ async function selectStatusMappings(states, options) {
171
238
  type: "select",
172
239
  name: "todo",
173
240
  message: 'Select status for "Todo" (pending tasks):',
174
- choices: stateChoices,
175
- initial: stateChoices.findIndex((c) => c.value === defaults.todo),
241
+ choices: devStateChoices,
242
+ initial: devStateChoices.findIndex((c) => c.value === devDefaults.todo),
176
243
  });
177
244
  const inProgressResponse = await prompts({
178
245
  type: "select",
179
246
  name: "in_progress",
180
247
  message: 'Select status for "In Progress" (working tasks):',
181
- choices: stateChoices,
182
- initial: stateChoices.findIndex((c) => c.value === defaults.in_progress),
248
+ choices: devStateChoices,
249
+ initial: devStateChoices.findIndex((c) => c.value === devDefaults.in_progress),
183
250
  });
184
251
  const doneResponse = await prompts({
185
252
  type: "select",
186
253
  name: "done",
187
254
  message: 'Select status for "Done" (completed tasks):',
188
- choices: stateChoices,
189
- initial: stateChoices.findIndex((c) => c.value === defaults.done),
255
+ choices: devStateChoices,
256
+ initial: devStateChoices.findIndex((c) => c.value === devDefaults.done),
190
257
  });
258
+ // Testing uses qa team states (or dev team if qa not set)
259
+ const testingStateChoices = testingStates.map((s) => ({
260
+ title: `${s.name} (${s.type})`,
261
+ value: s.name,
262
+ }));
191
263
  const testingChoices = [
192
264
  { title: "(None)", value: undefined },
193
- ...stateChoices,
265
+ ...testingStateChoices,
194
266
  ];
267
+ const testingMessage = qaStates && qaStates.length > 0
268
+ ? 'Select status for "Testing" (from QA team, for parent tasks):'
269
+ : 'Select status for "Testing" (optional, for parent tasks):';
195
270
  const testingResponse = await prompts({
196
271
  type: "select",
197
272
  name: "testing",
198
- message: 'Select status for "Testing" (optional, for parent tasks):',
273
+ message: testingMessage,
199
274
  choices: testingChoices,
200
- initial: defaults.testing
201
- ? testingChoices.findIndex((c) => c.value === defaults.testing)
275
+ initial: testingDefaults.testing
276
+ ? testingChoices.findIndex((c) => c.value === testingDefaults.testing)
277
+ : 0,
278
+ });
279
+ const blockedChoices = [
280
+ { title: "(None)", value: undefined },
281
+ ...devStateChoices,
282
+ ];
283
+ const blockedResponse = await prompts({
284
+ type: "select",
285
+ name: "blocked",
286
+ message: 'Select status for "Blocked" (optional, for blocked tasks):',
287
+ choices: blockedChoices,
288
+ initial: devDefaults.blocked
289
+ ? blockedChoices.findIndex((c) => c.value === devDefaults.blocked)
202
290
  : 0,
203
291
  });
204
292
  return {
205
- todo: todoResponse.todo || defaults.todo,
206
- in_progress: inProgressResponse.in_progress || defaults.in_progress,
207
- done: doneResponse.done || defaults.done,
293
+ todo: todoResponse.todo || devDefaults.todo,
294
+ in_progress: inProgressResponse.in_progress || devDefaults.in_progress,
295
+ done: doneResponse.done || devDefaults.done,
208
296
  testing: testingResponse.testing,
297
+ blocked: blockedResponse.blocked,
209
298
  };
210
299
  }
211
300
  async function updateGitignore(tttDir, interactive) {
@@ -254,6 +343,148 @@ async function updateGitignore(tttDir, interactive) {
254
343
  // Silently ignore gitignore errors
255
344
  }
256
345
  }
346
+ async function installClaudeCommands(interactive, statusSource) {
347
+ if (!interactive) {
348
+ return { installed: false, prefix: "" };
349
+ }
350
+ console.log("\n🤖 Claude Code Commands:");
351
+ // Ask if user wants to install commands
352
+ const { install } = await prompts({
353
+ type: "confirm",
354
+ name: "install",
355
+ message: "Install Claude Code commands? (work-on, done-job, sync-linear)",
356
+ initial: true,
357
+ });
358
+ if (!install) {
359
+ return { installed: false, prefix: "" };
360
+ }
361
+ // Ask for prefix
362
+ const { prefixChoice } = await prompts({
363
+ type: "select",
364
+ name: "prefixChoice",
365
+ message: "Command prefix style:",
366
+ choices: [
367
+ {
368
+ title: "No prefix (recommended)",
369
+ value: "",
370
+ description: "/work-on, /done-job, /sync-linear",
371
+ },
372
+ {
373
+ title: "ttt:",
374
+ value: "ttt:",
375
+ description: "/ttt:work-on, /ttt:done-job, /ttt:sync-linear",
376
+ },
377
+ {
378
+ title: "linear:",
379
+ value: "linear:",
380
+ description: "/linear:work-on, /linear:done-job, /linear:sync-linear",
381
+ },
382
+ {
383
+ title: "Custom...",
384
+ value: "custom",
385
+ description: "Enter your own prefix",
386
+ },
387
+ ],
388
+ initial: 0,
389
+ });
390
+ let prefix = prefixChoice || "";
391
+ if (prefixChoice === "custom") {
392
+ const { customPrefix } = await prompts({
393
+ type: "text",
394
+ name: "customPrefix",
395
+ message: "Enter custom prefix (e.g., 'my:'):",
396
+ initial: "",
397
+ });
398
+ prefix = customPrefix || "";
399
+ }
400
+ // Find templates directory
401
+ // Try multiple locations: installed package, local dev
402
+ const possibleTemplatePaths = [
403
+ path.join(__dirname, "..", "templates", "claude-code-commands"),
404
+ path.join(__dirname, "..", "..", "templates", "claude-code-commands"),
405
+ path.join(process.cwd(), "templates", "claude-code-commands"),
406
+ ];
407
+ let templateDir = null;
408
+ for (const p of possibleTemplatePaths) {
409
+ try {
410
+ await fs.access(p);
411
+ templateDir = p;
412
+ break;
413
+ }
414
+ catch {
415
+ // Try next path
416
+ }
417
+ }
418
+ if (!templateDir) {
419
+ // Try to get repo URL from package.json
420
+ let repoUrl = "https://github.com/wayne930242/team-toon-tack";
421
+ try {
422
+ const pkgPaths = [
423
+ path.join(__dirname, "..", "package.json"),
424
+ path.join(__dirname, "..", "..", "package.json"),
425
+ ];
426
+ for (const pkgPath of pkgPaths) {
427
+ try {
428
+ const pkgContent = await fs.readFile(pkgPath, "utf-8");
429
+ const pkg = JSON.parse(pkgContent);
430
+ if (pkg.repository?.url) {
431
+ // Parse git+https://github.com/user/repo.git format
432
+ repoUrl = pkg.repository.url
433
+ .replace(/^git\+/, "")
434
+ .replace(/\.git$/, "");
435
+ }
436
+ break;
437
+ }
438
+ catch {
439
+ // Try next path
440
+ }
441
+ }
442
+ }
443
+ catch {
444
+ // Use default URL
445
+ }
446
+ console.log(" ⚠ Could not find command templates. Please copy manually from:");
447
+ console.log(` ${repoUrl}/tree/main/templates/claude-code-commands`);
448
+ return { installed: false, prefix };
449
+ }
450
+ // Create .claude/commands directory
451
+ const commandsDir = path.join(process.cwd(), ".claude", "commands");
452
+ await fs.mkdir(commandsDir, { recursive: true });
453
+ // Copy and rename template files
454
+ const templateFiles = await fs.readdir(templateDir);
455
+ const commandFiles = templateFiles.filter((f) => f.endsWith(".md"));
456
+ for (const file of commandFiles) {
457
+ const baseName = file.replace(".md", "");
458
+ const newFileName = prefix ? `${prefix}${baseName}.md` : file;
459
+ const srcPath = path.join(templateDir, file);
460
+ const destPath = path.join(commandsDir, newFileName);
461
+ // Read template content
462
+ let content = await fs.readFile(srcPath, "utf-8");
463
+ // Update the name in frontmatter if prefix is used
464
+ if (prefix) {
465
+ content = content.replace(/^(---\s*\n[\s\S]*?name:\s*)(\S+)/m, `$1${prefix}${baseName}`);
466
+ }
467
+ // Modify content based on statusSource for work-on and done-job
468
+ if (statusSource === "local") {
469
+ if (baseName === "work-on" || baseName.endsWith("work-on")) {
470
+ // Update description for local mode
471
+ content = content.replace(/Select a task and update status to "In Progress" on both local and Linear\./, 'Select a task and update local status to "In Progress". (Linear will be updated when you run `sync --update`)');
472
+ // Add reminder after Complete section
473
+ content = content.replace(/Use `?\/done-job`? to mark task as completed/, "Use `/done-job` to mark task as completed\n\n### 7. Sync to Linear\n\nWhen ready to update Linear with all your changes:\n\n```bash\nttt sync --update\n```");
474
+ }
475
+ if (baseName === "done-job" || baseName.endsWith("done-job")) {
476
+ // Update description for local mode
477
+ content = content.replace(/Mark a task as done and update Linear with commit details\./, "Mark a task as done locally. (Run `ttt sync --update` to push changes to Linear)");
478
+ // Add reminder at the end
479
+ content = content.replace(/## What It Does\n\n- Linear issue status → "Done"/, "## What It Does\n\n- Local status → `completed`");
480
+ content += `\n## Sync to Linear\n\nAfter completing tasks, push all changes to Linear:\n\n\`\`\`bash\nttt sync --update\n\`\`\`\n`;
481
+ }
482
+ }
483
+ await fs.writeFile(destPath, content, "utf-8");
484
+ console.log(` ✓ .claude/commands/${newFileName}`);
485
+ }
486
+ return { installed: true, prefix };
487
+ }
257
488
  async function init() {
258
489
  const args = process.argv.slice(2);
259
490
  const options = parseArgs(args);
@@ -308,25 +539,76 @@ async function init() {
308
539
  if (selectedTeams.length > 1) {
309
540
  console.log(` Primary: ${primaryTeam.name}`);
310
541
  }
311
- // Fetch data from primary team
312
- const selectedTeam = await client.team(primaryTeam.id);
313
- const members = await selectedTeam.members();
314
- const users = members.nodes;
542
+ // Fetch data from ALL teams (not just primary) to support cross-team operations
543
+ console.log(` Fetching data from ${teams.length} teams...`);
544
+ // Collect users from all teams, but labels only from primary team
545
+ // States are stored per-team for status mapping selection
546
+ const allUsers = [];
547
+ const allLabels = [];
548
+ const allStates = [];
549
+ const teamStatesMap = new Map(); // team.id -> states
550
+ const seenUserIds = new Set();
551
+ const seenLabelIds = new Set();
552
+ const seenStateIds = new Set();
553
+ for (const team of teams) {
554
+ try {
555
+ const teamData = await client.team(team.id);
556
+ const members = await teamData.members();
557
+ for (const user of members.nodes) {
558
+ if (!seenUserIds.has(user.id)) {
559
+ seenUserIds.add(user.id);
560
+ allUsers.push(user);
561
+ }
562
+ }
563
+ // Labels: only from primary team (dev team)
564
+ if (team.id === primaryTeam.id) {
565
+ const labelsData = await client.issueLabels({
566
+ filter: { team: { id: { eq: team.id } } },
567
+ });
568
+ for (const label of labelsData.nodes) {
569
+ if (!seenLabelIds.has(label.id)) {
570
+ seenLabelIds.add(label.id);
571
+ allLabels.push(label);
572
+ }
573
+ }
574
+ }
575
+ // States: store per-team and also collect all
576
+ const statesData = await client.workflowStates({
577
+ filter: { team: { id: { eq: team.id } } },
578
+ });
579
+ const teamStates = [];
580
+ for (const state of statesData.nodes) {
581
+ teamStates.push(state);
582
+ if (!seenStateIds.has(state.id)) {
583
+ seenStateIds.add(state.id);
584
+ allStates.push(state);
585
+ }
586
+ }
587
+ teamStatesMap.set(team.id, teamStates);
588
+ }
589
+ catch (error) {
590
+ console.warn(` ⚠ Could not fetch data for team ${team.name}, skipping...`);
591
+ }
592
+ }
593
+ const users = allUsers;
594
+ const labels = allLabels;
595
+ const states = allStates;
596
+ // Get team-specific states for status mapping
597
+ const primaryTeamStates = teamStatesMap.get(primaryTeam.id) || [];
315
598
  console.log(` Users: ${users.length}`);
316
- const labelsData = await client.issueLabels({
317
- filter: { team: { id: { eq: primaryTeam.id } } },
318
- });
319
- const labels = labelsData.nodes;
320
- console.log(` Labels: ${labels.length}`);
321
- const statesData = await client.workflowStates({
322
- filter: { team: { id: { eq: primaryTeam.id } } },
323
- });
324
- const states = statesData.nodes;
599
+ console.log(` Labels: ${labels.length} (from ${primaryTeam.name})`);
600
+ console.log(` Workflow states: ${states.length}`);
601
+ // Get cycle from primary team (for current work tracking)
602
+ const selectedTeam = await client.team(primaryTeam.id);
325
603
  const currentCycle = (await selectedTeam.activeCycle);
326
604
  // User selections
327
605
  const currentUser = await selectUser(users, options);
328
606
  const defaultLabel = await selectLabelFilter(labels, options);
329
- const statusTransitions = await selectStatusMappings(states, options);
607
+ const statusSource = await selectStatusSource(options);
608
+ const qaPmTeam = await selectQaPmTeam(teams, primaryTeam, options);
609
+ // Get qa team states for testing status mapping (fallback to primary team if not set)
610
+ const qaTeamStates = qaPmTeam ? teamStatesMap.get(qaPmTeam.id) : undefined;
611
+ const statusTransitions = await selectStatusMappings(primaryTeamStates, qaTeamStates, options);
330
612
  // Build config
331
613
  const config = buildConfig(teams, users, labels, states, statusTransitions, currentCycle ?? undefined);
332
614
  // Find keys
@@ -335,7 +617,11 @@ async function init() {
335
617
  const selectedTeamKeys = selectedTeams
336
618
  .map((team) => findTeamKey(config.teams, team.id))
337
619
  .filter((key) => key !== undefined);
338
- const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel);
620
+ const qaPmTeamKey = qaPmTeam
621
+ ? findTeamKey(config.teams, qaPmTeam.id)
622
+ : undefined;
623
+ const localConfig = buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, undefined, // excludeLabels
624
+ statusSource, qaPmTeamKey);
339
625
  // Write config files
340
626
  console.log("\n📝 Writing configuration files...");
341
627
  await fs.mkdir(paths.baseDir, { recursive: true });
@@ -372,10 +658,14 @@ async function init() {
372
658
  localConfig.team = existingLocal.team;
373
659
  if (existingLocal.teams)
374
660
  localConfig.teams = existingLocal.teams;
661
+ if (existingLocal.qa_pm_team)
662
+ localConfig.qa_pm_team = existingLocal.qa_pm_team;
375
663
  if (existingLocal.label)
376
664
  localConfig.label = existingLocal.label;
377
665
  if (existingLocal.exclude_labels)
378
666
  localConfig.exclude_labels = existingLocal.exclude_labels;
667
+ if (existingLocal.status_source)
668
+ localConfig.status_source = existingLocal.status_source;
379
669
  }
380
670
  }
381
671
  catch {
@@ -387,6 +677,8 @@ async function init() {
387
677
  // Update .gitignore
388
678
  const tttDir = paths.baseDir.replace(/^\.\//, "");
389
679
  await updateGitignore(tttDir, options.interactive ?? true);
680
+ // Install Claude Code commands
681
+ const { installed: commandsInstalled, prefix: commandPrefix } = await installClaudeCommands(options.interactive ?? true, statusSource);
390
682
  // Summary
391
683
  console.log("\n✅ Initialization complete!\n");
392
684
  console.log("Configuration summary:");
@@ -396,6 +688,10 @@ async function init() {
396
688
  }
397
689
  console.log(` User: ${currentUser.displayName || currentUser.name} (${currentUser.email})`);
398
690
  console.log(` Label filter: ${defaultLabel || "(none)"}`);
691
+ console.log(` Status source: ${statusSource === "local" ? "local (use 'sync --update' to push)" : "remote (immediate sync)"}`);
692
+ if (qaPmTeam) {
693
+ console.log(` QA/PM team: ${qaPmTeam.name}`);
694
+ }
399
695
  console.log(` (Use 'ttt config filters' to set excluded labels/users)`);
400
696
  if (currentCycle) {
401
697
  console.log(` Cycle: ${currentCycle.name || `Cycle #${currentCycle.number}`}`);
@@ -407,10 +703,24 @@ async function init() {
407
703
  if (statusTransitions.testing) {
408
704
  console.log(` Testing: ${statusTransitions.testing}`);
409
705
  }
706
+ if (statusTransitions.blocked) {
707
+ console.log(` Blocked: ${statusTransitions.blocked}`);
708
+ }
709
+ if (commandsInstalled) {
710
+ const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
711
+ console.log(` Claude commands: /${cmdPrefix}work-on, /${cmdPrefix}done-job, /${cmdPrefix}sync-linear`);
712
+ }
410
713
  console.log("\nNext steps:");
411
714
  console.log(" 1. Set LINEAR_API_KEY in your shell profile:");
412
715
  console.log(` export LINEAR_API_KEY="${apiKey}"`);
413
- console.log(" 2. Run sync: bun run sync");
414
- console.log(" 3. Start working: bun run work-on");
716
+ console.log(" 2. Run sync: ttt sync");
717
+ if (commandsInstalled) {
718
+ const cmdPrefix = commandPrefix ? `${commandPrefix}` : "";
719
+ console.log(` 3. In Claude Code: /${cmdPrefix}work-on next`);
720
+ console.log(`\n💡 Tip: Edit .claude/commands/${cmdPrefix}work-on.md to customize the "Verify" section for your project.`);
721
+ }
722
+ else {
723
+ console.log(" 3. Start working: ttt work-on");
724
+ }
415
725
  }
416
726
  init().catch(console.error);
@@ -38,4 +38,4 @@ export declare function getDefaultStatusTransitions(states: LinearState[]): Stat
38
38
  export declare function buildConfig(teams: LinearTeam[], users: LinearUser[], labels: LinearLabel[], states: LinearState[], statusTransitions: StatusTransitions, currentCycle?: LinearCycle): Config;
39
39
  export declare function findUserKey(usersConfig: Record<string, UserConfig>, userId: string): string;
40
40
  export declare function findTeamKey(teamsConfig: Record<string, TeamConfig>, teamId: string): string;
41
- export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[]): LocalConfig;
41
+ export declare function buildLocalConfig(currentUserKey: string, primaryTeamKey: string, selectedTeamKeys: string[], defaultLabel?: string, excludeLabels?: string[], statusSource?: "remote" | "local", qaPmTeam?: string): LocalConfig;
@@ -67,11 +67,13 @@ export function getDefaultStatusTransitions(states) {
67
67
  findStatusByKeyword(states, ["done", "complete"]) ||
68
68
  "Done";
69
69
  const defaultTesting = findStatusByKeyword(states, ["testing", "review"]) || undefined;
70
+ const defaultBlocked = findStatusByKeyword(states, ["blocked", "on hold", "waiting"]) || undefined;
70
71
  return {
71
72
  todo: defaultTodo,
72
73
  in_progress: defaultInProgress,
73
74
  done: defaultDone,
74
75
  testing: defaultTesting,
76
+ blocked: defaultBlocked,
75
77
  };
76
78
  }
77
79
  export function buildConfig(teams, users, labels, states, statusTransitions, currentCycle) {
@@ -106,12 +108,14 @@ export function findTeamKey(teamsConfig, teamId) {
106
108
  return (Object.entries(teamsConfig).find(([_, t]) => t.id === teamId)?.[0] ||
107
109
  Object.keys(teamsConfig)[0]);
108
110
  }
109
- export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels) {
111
+ export function buildLocalConfig(currentUserKey, primaryTeamKey, selectedTeamKeys, defaultLabel, excludeLabels, statusSource, qaPmTeam) {
110
112
  return {
111
113
  current_user: currentUserKey,
112
114
  team: primaryTeamKey,
113
115
  teams: selectedTeamKeys.length > 1 ? selectedTeamKeys : undefined,
116
+ qa_pm_team: qaPmTeam,
114
117
  label: defaultLabel,
115
118
  exclude_labels: excludeLabels && excludeLabels.length > 0 ? excludeLabels : undefined,
119
+ status_source: statusSource,
116
120
  };
117
121
  }
@@ -11,7 +11,7 @@ export function getStatusIcon(localStatus) {
11
11
  return "✅";
12
12
  case "in-progress":
13
13
  return "🔄";
14
- case "blocked-backend":
14
+ case "blocked":
15
15
  return "🚫";
16
16
  default:
17
17
  return "📋";
@@ -39,14 +39,25 @@ export function displayTaskStatus(task) {
39
39
  }
40
40
  export function displayTaskDescription(task) {
41
41
  if (task.description) {
42
- console.log(`\n📝 Description:\n${task.description}`);
42
+ let description = task.description;
43
+ // Replace Linear URLs with local paths from attachments
44
+ if (task.attachments) {
45
+ for (const att of task.attachments) {
46
+ if (att.localPath && att.url) {
47
+ description = description.split(att.url).join(att.localPath);
48
+ }
49
+ }
50
+ }
51
+ console.log(`\n📝 Description:\n${description}`);
43
52
  }
44
53
  }
45
54
  export function displayTaskAttachments(task) {
46
55
  if (task.attachments && task.attachments.length > 0) {
47
56
  console.log(`\n📎 Attachments:`);
48
57
  for (const att of task.attachments) {
49
- console.log(` - ${att.title}: ${att.url}`);
58
+ // Prefer local path for Linear images (Linear URLs are not accessible)
59
+ const displayPath = att.localPath || att.url;
60
+ console.log(` - ${att.title}: ${displayPath}`);
50
61
  }
51
62
  }
52
63
  }
@@ -0,0 +1,9 @@
1
+ export declare function isLinearImageUrl(url: string): boolean;
2
+ /**
3
+ * Extract Linear image URLs from markdown text (description, comments)
4
+ */
5
+ export declare function extractLinearImageUrls(text: string): string[];
6
+ export declare function downloadLinearFile(url: string, issueId: string, attachmentId: string, outputDir: string): Promise<string | undefined>;
7
+ export declare const downloadLinearImage: typeof downloadLinearFile;
8
+ export declare function clearIssueImages(outputDir: string, issueId: string): Promise<void>;
9
+ export declare function ensureOutputDir(outputDir: string): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const LINEAR_IMAGE_DOMAINS = [
4
+ "uploads.linear.app",
5
+ "linear-uploads.s3.us-west-2.amazonaws.com",
6
+ ];
7
+ export function isLinearImageUrl(url) {
8
+ try {
9
+ const parsed = new URL(url);
10
+ return LINEAR_IMAGE_DOMAINS.some((domain) => parsed.host.includes(domain));
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Extract Linear image URLs from markdown text (description, comments)
18
+ */
19
+ export function extractLinearImageUrls(text) {
20
+ const urls = [];
21
+ // Match markdown image syntax ![alt](url) and plain URLs
22
+ const patterns = [
23
+ /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g, // ![alt](url)
24
+ /(https?:\/\/uploads\.linear\.app\/[^\s)>\]]+)/g, // Plain Linear upload URLs
25
+ ];
26
+ for (const pattern of patterns) {
27
+ let match;
28
+ while ((match = pattern.exec(text)) !== null) {
29
+ const url = match[1];
30
+ if (isLinearImageUrl(url) && !urls.includes(url)) {
31
+ urls.push(url);
32
+ }
33
+ }
34
+ }
35
+ return urls;
36
+ }
37
+ function getFileExtension(url, contentType) {
38
+ // Try to get extension from content-type header
39
+ if (contentType) {
40
+ // Image types
41
+ const imageMatch = contentType.match(/image\/(\w+)/);
42
+ if (imageMatch) {
43
+ const ext = imageMatch[1] === "jpeg" ? "jpg" : imageMatch[1];
44
+ return { ext, isImage: true };
45
+ }
46
+ // Video types
47
+ const videoMatch = contentType.match(/video\/(\w+)/);
48
+ if (videoMatch) {
49
+ const videoExt = videoMatch[1] === "quicktime" ? "mov" : videoMatch[1];
50
+ return { ext: videoExt, isImage: false };
51
+ }
52
+ }
53
+ // Fallback: try to get from URL path
54
+ const urlPath = new URL(url).pathname;
55
+ const ext = path.extname(urlPath).slice(1).toLowerCase();
56
+ if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
57
+ return { ext: ext === "jpeg" ? "jpg" : ext, isImage: true };
58
+ }
59
+ if (["mov", "mp4", "webm", "avi"].includes(ext)) {
60
+ return { ext, isImage: false };
61
+ }
62
+ return { ext: "png", isImage: true }; // Default assume image
63
+ }
64
+ export async function downloadLinearFile(url, issueId, attachmentId, outputDir) {
65
+ try {
66
+ // Linear files require authentication
67
+ const headers = {};
68
+ if (process.env.LINEAR_API_KEY) {
69
+ headers["Authorization"] = process.env.LINEAR_API_KEY;
70
+ }
71
+ const response = await fetch(url, { headers });
72
+ if (!response.ok) {
73
+ console.error(`Failed to download file: ${response.status}`);
74
+ return undefined;
75
+ }
76
+ const contentType = response.headers.get("content-type") || undefined;
77
+ const { ext } = getFileExtension(url, contentType);
78
+ const filename = `${issueId}_${attachmentId}.${ext}`;
79
+ const filepath = path.join(outputDir, filename);
80
+ const buffer = await response.arrayBuffer();
81
+ await fs.writeFile(filepath, Buffer.from(buffer));
82
+ return filepath;
83
+ }
84
+ catch (error) {
85
+ console.error(`Error downloading file: ${error}`);
86
+ return undefined;
87
+ }
88
+ }
89
+ // Alias for backwards compatibility
90
+ export const downloadLinearImage = downloadLinearFile;
91
+ export async function clearIssueImages(outputDir, issueId) {
92
+ try {
93
+ const files = await fs.readdir(outputDir);
94
+ const issuePrefix = `${issueId}_`;
95
+ for (const file of files) {
96
+ if (file.startsWith(issuePrefix)) {
97
+ await fs.unlink(path.join(outputDir, file));
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ // Directory doesn't exist or other error, ignore
103
+ }
104
+ }
105
+ export async function ensureOutputDir(outputDir) {
106
+ await fs.mkdir(outputDir, { recursive: true });
107
+ }
@@ -8,4 +8,4 @@ export declare function getWorkflowStates(config: Config, teamKey: string): Prom
8
8
  export declare function getStatusTransitions(config: Config): StatusTransitions;
9
9
  export declare function updateIssueStatus(linearId: string, targetStatusName: string, config: Config, teamKey: string): Promise<boolean>;
10
10
  export declare function addComment(issueId: string, body: string): Promise<boolean>;
11
- export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked-backend", config: Config): string | undefined;
11
+ export declare function mapLocalStatusToLinear(localStatus: "pending" | "in-progress" | "completed" | "blocked", config: Config): string | undefined;
@@ -55,6 +55,8 @@ export function mapLocalStatusToLinear(localStatus, config) {
55
55
  return transitions.in_progress;
56
56
  case "completed":
57
57
  return transitions.done;
58
+ case "blocked":
59
+ return transitions.blocked;
58
60
  default:
59
61
  return undefined;
60
62
  }
@@ -6,7 +6,7 @@ const LOCAL_STATUS_ORDER = [
6
6
  "pending",
7
7
  "in-progress",
8
8
  "completed",
9
- "blocked-backend",
9
+ "blocked",
10
10
  ];
11
11
  function parseArgs(args) {
12
12
  let issueId;
@@ -41,7 +41,7 @@ Options:
41
41
  pending Set to pending
42
42
  in-progress Set to in-progress
43
43
  completed Set to completed
44
- blocked Set to blocked-backend
44
+ blocked Set to blocked (syncs to Linear if configured)
45
45
  todo Set Linear to Todo status
46
46
  done Set Linear to Done status
47
47
 
@@ -105,17 +105,8 @@ Examples:
105
105
  const newIndex = Math.max(currentIndex - 2, 0);
106
106
  newLocalStatus = LOCAL_STATUS_ORDER[newIndex];
107
107
  }
108
- else if ([
109
- "pending",
110
- "in-progress",
111
- "completed",
112
- "blocked-backend",
113
- "blocked",
114
- ].includes(setStatus)) {
115
- newLocalStatus =
116
- setStatus === "blocked"
117
- ? "blocked-backend"
118
- : setStatus;
108
+ else if (["pending", "in-progress", "completed", "blocked"].includes(setStatus)) {
109
+ newLocalStatus = setStatus;
119
110
  }
120
111
  else if (["todo", "in_progress", "done", "testing"].includes(setStatus)) {
121
112
  const transitions = getStatusTransitions(config);
@@ -143,8 +134,9 @@ Examples:
143
134
  task.localStatus = newLocalStatus;
144
135
  needsSave = true;
145
136
  }
146
- // Update Linear status
147
- if (newLinearStatus || newLocalStatus) {
137
+ // Update Linear status (only if status_source is 'remote' or not set)
138
+ const statusSource = localConfig.status_source || "remote";
139
+ if (statusSource === "remote" && (newLinearStatus || newLocalStatus)) {
148
140
  let targetStateName = newLinearStatus;
149
141
  if (!targetStateName && newLocalStatus) {
150
142
  targetStateName = mapLocalStatusToLinear(newLocalStatus, config);
@@ -158,12 +150,20 @@ Examples:
158
150
  }
159
151
  }
160
152
  }
153
+ else if (statusSource === "local" &&
154
+ (newLinearStatus || newLocalStatus)) {
155
+ // Local mode: just note that Linear wasn't updated
156
+ needsSave = true;
157
+ }
161
158
  // Save if anything changed
162
159
  if (needsSave) {
163
160
  await saveCycleData(data);
164
161
  if (newLocalStatus && newLocalStatus !== oldLocalStatus) {
165
162
  console.log(`Local: ${task.id} ${oldLocalStatus} → ${newLocalStatus}`);
166
163
  }
164
+ if (statusSource === "local") {
165
+ console.log(`(Linear status not updated - use 'sync --update' to push)`);
166
+ }
167
167
  }
168
168
  else if (newLocalStatus) {
169
169
  console.log(`Local: ${task.id} already ${newLocalStatus}`);
@@ -1,15 +1,20 @@
1
- import { getLinearClient, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
1
+ import { getLinearClient, getPaths, getPrioritySortIndex, getTeamId, loadConfig, loadCycleData, loadLocalConfig, saveConfig, saveCycleData, } from "./utils.js";
2
+ import { clearIssueImages, downloadLinearImage, ensureOutputDir, extractLinearImageUrls, isLinearImageUrl, } from "./lib/images.js";
2
3
  async function sync() {
3
4
  const args = process.argv.slice(2);
4
5
  // Handle help flag
5
6
  if (args.includes("--help") || args.includes("-h")) {
6
- console.log(`Usage: ttt sync [issue-id]
7
+ console.log(`Usage: ttt sync [issue-id] [--update]
7
8
 
8
9
  Sync issues from Linear to local cycle.ttt file.
9
10
 
10
11
  Arguments:
11
12
  issue-id Optional. Sync only this specific issue (e.g., MP-624)
12
13
 
14
+ Options:
15
+ --update Push local status changes to Linear (for local mode users)
16
+ This updates Linear with your local in-progress/completed statuses
17
+
13
18
  What it does:
14
19
  - Fetches active cycle from Linear
15
20
  - Downloads all issues matching configured filters
@@ -19,15 +24,21 @@ What it does:
19
24
  Examples:
20
25
  ttt sync # Sync all matching issues
21
26
  ttt sync MP-624 # Sync only this specific issue
27
+ ttt sync --update # Push local changes to Linear, then sync
22
28
  ttt sync -d .ttt # Sync using .ttt directory`);
23
29
  process.exit(0);
24
30
  }
31
+ // Check for --update flag
32
+ const shouldUpdate = args.includes("--update");
25
33
  // Parse issue ID argument (if provided)
26
34
  const singleIssueId = args.find((arg) => !arg.startsWith("-") && arg.match(/^[A-Z]+-\d+$/i));
27
35
  const config = await loadConfig();
28
36
  const localConfig = await loadLocalConfig();
29
37
  const client = getLinearClient();
30
38
  const teamId = getTeamId(config, localConfig.team);
39
+ const { outputPath } = getPaths();
40
+ // Ensure output directory exists
41
+ await ensureOutputDir(outputPath);
31
42
  // Build excluded labels set
32
43
  const excludedLabels = new Set(localConfig.exclude_labels ?? []);
33
44
  // Phase 1: Fetch active cycle directly from team
@@ -91,6 +102,50 @@ Examples:
91
102
  const testingStateId = statusTransitions.testing
92
103
  ? stateMap.get(statusTransitions.testing)
93
104
  : undefined;
105
+ const inProgressStateId = stateMap.get(statusTransitions.in_progress);
106
+ // Phase 2.5: Push local status changes to Linear (if --update flag)
107
+ if (shouldUpdate && existingData) {
108
+ console.log("Pushing local status changes to Linear...");
109
+ let pushCount = 0;
110
+ for (const task of existingData.tasks) {
111
+ // Map local status to Linear status
112
+ let targetStateId;
113
+ if (task.localStatus === "in-progress" && inProgressStateId) {
114
+ // Check if Linear status is not already in-progress
115
+ if (task.status !== statusTransitions.in_progress) {
116
+ targetStateId = inProgressStateId;
117
+ }
118
+ }
119
+ else if (task.localStatus === "completed" && testingStateId) {
120
+ // Check if Linear status is not already testing/done
121
+ const terminalStatuses = [statusTransitions.done];
122
+ if (statusTransitions.testing)
123
+ terminalStatuses.push(statusTransitions.testing);
124
+ if (!terminalStatuses.includes(task.status)) {
125
+ targetStateId = testingStateId;
126
+ }
127
+ }
128
+ if (targetStateId) {
129
+ try {
130
+ await client.updateIssue(task.linearId, { stateId: targetStateId });
131
+ const targetName = targetStateId === inProgressStateId
132
+ ? statusTransitions.in_progress
133
+ : statusTransitions.testing;
134
+ console.log(` ${task.id} → ${targetName}`);
135
+ pushCount++;
136
+ }
137
+ catch (e) {
138
+ console.error(` Failed to update ${task.id}:`, e);
139
+ }
140
+ }
141
+ }
142
+ if (pushCount > 0) {
143
+ console.log(`Pushed ${pushCount} status updates to Linear.`);
144
+ }
145
+ else {
146
+ console.log("No local changes to push.");
147
+ }
148
+ }
94
149
  // Phase 3: Build existing tasks map for preserving local status
95
150
  const existingTasksMap = new Map(existingData?.tasks.map((t) => [t.id, t]));
96
151
  // Phase 4: Fetch current issues with full content
@@ -145,13 +200,49 @@ Examples:
145
200
  const parent = await issue.parent;
146
201
  const attachmentsData = await issue.attachments();
147
202
  const commentsData = await issue.comments();
148
- // Build attachments list
149
- const attachments = attachmentsData.nodes.map((a) => ({
150
- id: a.id,
151
- title: a.title,
152
- url: a.url,
153
- sourceType: a.sourceType ?? undefined,
154
- }));
203
+ // Clear old images for this issue before downloading new ones
204
+ await clearIssueImages(outputPath, issue.identifier);
205
+ // Build attachments list and download Linear images
206
+ const attachments = [];
207
+ for (const a of attachmentsData.nodes) {
208
+ const attachment = {
209
+ id: a.id,
210
+ title: a.title,
211
+ url: a.url,
212
+ sourceType: a.sourceType ?? undefined,
213
+ };
214
+ // Download Linear domain images
215
+ if (isLinearImageUrl(a.url)) {
216
+ const localPath = await downloadLinearImage(a.url, issue.identifier, a.id, outputPath);
217
+ if (localPath) {
218
+ attachment.localPath = localPath;
219
+ }
220
+ }
221
+ attachments.push(attachment);
222
+ }
223
+ // Extract and download images from description
224
+ if (issue.description) {
225
+ const descriptionImageUrls = extractLinearImageUrls(issue.description);
226
+ for (const url of descriptionImageUrls) {
227
+ // Generate a short ID from URL (last segment of path)
228
+ const urlPath = new URL(url).pathname;
229
+ const segments = urlPath.split("/").filter(Boolean);
230
+ const imageId = segments[segments.length - 1] || `desc_${Date.now()}`;
231
+ // Skip if already in attachments
232
+ if (attachments.some((a) => a.url === url))
233
+ continue;
234
+ const localPath = await downloadLinearImage(url, issue.identifier, imageId, outputPath);
235
+ if (localPath) {
236
+ attachments.push({
237
+ id: imageId,
238
+ title: `Description Image`,
239
+ url: url,
240
+ sourceType: "description",
241
+ localPath: localPath,
242
+ });
243
+ }
244
+ }
245
+ }
155
246
  // Build comments list
156
247
  const comments = await Promise.all(commentsData.nodes.map(async (c) => {
157
248
  const user = await c.user;
@@ -4,6 +4,7 @@ export declare function getPaths(): {
4
4
  configPath: string;
5
5
  cyclePath: string;
6
6
  localPath: string;
7
+ outputPath: string;
7
8
  };
8
9
  export interface TeamConfig {
9
10
  id: string;
@@ -32,6 +33,7 @@ export interface StatusTransitions {
32
33
  in_progress: string;
33
34
  done: string;
34
35
  testing?: string;
36
+ blocked?: string;
35
37
  }
36
38
  export interface Config {
37
39
  teams: Record<string, TeamConfig>;
@@ -58,6 +60,7 @@ export interface Attachment {
58
60
  title: string;
59
61
  url: string;
60
62
  sourceType?: string;
63
+ localPath?: string;
61
64
  }
62
65
  export interface Comment {
63
66
  id: string;
@@ -70,7 +73,7 @@ export interface Task {
70
73
  linearId: string;
71
74
  title: string;
72
75
  status: string;
73
- localStatus: "pending" | "in-progress" | "completed" | "blocked-backend";
76
+ localStatus: "pending" | "in-progress" | "completed" | "blocked";
74
77
  assignee?: string;
75
78
  priority: number;
76
79
  labels: string[];
@@ -92,8 +95,10 @@ export interface LocalConfig {
92
95
  current_user: string;
93
96
  team: string;
94
97
  teams?: string[];
98
+ qa_pm_team?: string;
95
99
  exclude_labels?: string[];
96
100
  label?: string;
101
+ status_source?: "remote" | "local";
97
102
  }
98
103
  export declare function fileExists(filePath: string): Promise<boolean>;
99
104
  export declare function loadConfig(): Promise<Config>;
@@ -19,12 +19,14 @@ const BASE_DIR = getBaseDir();
19
19
  const CONFIG_PATH = path.join(BASE_DIR, "config.toon");
20
20
  const CYCLE_PATH = path.join(BASE_DIR, "cycle.toon");
21
21
  const LOCAL_PATH = path.join(BASE_DIR, "local.toon");
22
+ const OUTPUT_PATH = path.join(BASE_DIR, "output");
22
23
  export function getPaths() {
23
24
  return {
24
25
  baseDir: BASE_DIR,
25
26
  configPath: CONFIG_PATH,
26
27
  cyclePath: CYCLE_PATH,
27
28
  localPath: LOCAL_PATH,
29
+ outputPath: OUTPUT_PATH,
28
30
  };
29
31
  }
30
32
  // Linear priority value to name mapping (fixed by Linear API)
@@ -83,8 +83,11 @@ Examples:
83
83
  // Mark as In Progress
84
84
  if (task.localStatus === "pending") {
85
85
  task.localStatus = "in-progress";
86
- // Update Linear
87
- if (task.linearId && process.env.LINEAR_API_KEY) {
86
+ // Update Linear (only if status_source is 'remote' or not set)
87
+ const statusSource = localConfig.status_source || "remote";
88
+ if (statusSource === "remote" &&
89
+ task.linearId &&
90
+ process.env.LINEAR_API_KEY) {
88
91
  const transitions = getStatusTransitions(config);
89
92
  const success = await updateIssueStatus(task.linearId, transitions.in_progress, config, localConfig.team);
90
93
  if (success) {
@@ -94,6 +97,9 @@ Examples:
94
97
  }
95
98
  await saveCycleData(data);
96
99
  console.log(`Local: ${task.id} → in-progress`);
100
+ if (statusSource === "local") {
101
+ console.log(`(Linear status not updated - use 'sync --update' to push)`);
102
+ }
97
103
  }
98
104
  // Display task info
99
105
  displayTaskFull(task, "👷");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "team-toon-tack",
3
- "version": "1.7.1",
3
+ "version": "2.0.1",
4
4
  "description": "Linear task sync & management CLI with TOON format",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,17 @@ Script displays title, description, priority, labels, and attachments.
30
30
  3. Implement the fix/feature
31
31
  4. Commit with conventional format
32
32
 
33
- ### 4. Verify
33
+ ### 4. Handle Blockers (if any)
34
+
35
+ If you encounter a blocker (waiting for backend, design, external dependency):
36
+
37
+ ```bash
38
+ ttt status --set blocked
39
+ ```
40
+
41
+ Add a comment explaining the blocker, then move to another task.
42
+
43
+ ### 5. Verify
34
44
 
35
45
  Run project-required verification before completing:
36
46
 
@@ -39,7 +49,7 @@ Run project-required verification before completing:
39
49
  # (e.g., type-check, lint, test, build)
40
50
  ```
41
51
 
42
- ### 5. Complete
52
+ ### 6. Complete
43
53
 
44
54
  Use `/done-job` to mark task as completed
45
55