@bretwardjames/tw-bridge 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import fs2 from "fs";
5
- import path3 from "path";
6
- import os2 from "os";
4
+ import fs3 from "fs";
5
+ import path4 from "path";
6
+ import os3 from "os";
7
7
 
8
8
  // src/config.ts
9
9
  import fs from "fs";
@@ -30,7 +30,7 @@ function loadConfig() {
30
30
  function saveConfig(config) {
31
31
  const configPath = findConfigPath() ?? CONFIG_PATHS[0];
32
32
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
33
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
33
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
34
34
  return configPath;
35
35
  }
36
36
 
@@ -125,9 +125,11 @@ var GhpAdapter = class {
125
125
  process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
126
126
  return;
127
127
  }
128
- process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}
128
+ const parallel = process.env.TW_BRIDGE_MODE === "parallel";
129
+ const args = parallel ? ["start", issueNumber, "--parallel"] : ["start", issueNumber];
130
+ process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}${parallel ? " (parallel)" : ""}
129
131
  `);
130
- const result = spawnSync("ghp", ["start", issueNumber], {
132
+ const result = spawnSync("ghp", args, {
131
133
  cwd: this.config.cwd,
132
134
  stdio: [ttyFd, ttyFd, ttyFd]
133
135
  });
@@ -150,9 +152,430 @@ var GhpAdapter = class {
150
152
  }
151
153
  };
152
154
 
155
+ // src/adapters/asana.ts
156
+ import fs2 from "fs";
157
+ import path3 from "path";
158
+ import os2 from "os";
159
+ import { execSync } from "child_process";
160
+ import { createInterface } from "readline/promises";
161
+ import { stdin, stdout } from "process";
162
+ var API_BASE = "https://app.asana.com/api/1.0";
163
+ var OAUTH_AUTHORIZE = "https://app.asana.com/-/oauth_authorize";
164
+ var OAUTH_TOKEN = "https://app.asana.com/-/oauth_token";
165
+ var OOB_REDIRECT = "urn:ietf:wg:oauth:2.0:oob";
166
+ var TOKEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", "asana-tokens.json");
167
+ function loadTokens() {
168
+ try {
169
+ return JSON.parse(fs2.readFileSync(TOKEN_FILE, "utf-8"));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ function saveTokens(tokens) {
175
+ fs2.mkdirSync(path3.dirname(TOKEN_FILE), { recursive: true });
176
+ fs2.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 384 });
177
+ }
178
+ async function refreshAccessToken(tokens) {
179
+ const now = Date.now();
180
+ if (tokens.access_token && tokens.expires_at > now + 3e5) {
181
+ return tokens.access_token;
182
+ }
183
+ const res = await fetch(OAUTH_TOKEN, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
186
+ body: new URLSearchParams({
187
+ grant_type: "refresh_token",
188
+ client_id: tokens.client_id,
189
+ client_secret: tokens.client_secret,
190
+ refresh_token: tokens.refresh_token
191
+ })
192
+ });
193
+ if (!res.ok) {
194
+ const body = await res.text();
195
+ throw new Error(`Token refresh failed (${res.status}): ${body}`);
196
+ }
197
+ const data = await res.json();
198
+ tokens.access_token = data.access_token;
199
+ tokens.expires_at = now + (data.expires_in ?? 3600) * 1e3;
200
+ saveTokens(tokens);
201
+ return tokens.access_token;
202
+ }
203
+ async function oauthLogin(clientId, clientSecret, rl) {
204
+ const authUrl = `${OAUTH_AUTHORIZE}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(OOB_REDIRECT)}&response_type=code`;
205
+ console.log(`
206
+ Opening browser for authorization...`);
207
+ console.log(` If it doesn't open, visit:
208
+ ${authUrl}
209
+ `);
210
+ try {
211
+ execSync(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
212
+ stdio: "ignore"
213
+ });
214
+ } catch {
215
+ }
216
+ const code = (await rl.question(" Paste the authorization code: ")).trim();
217
+ if (!code) throw new Error("No authorization code provided");
218
+ const tokenRes = await fetch(OAUTH_TOKEN, {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
221
+ body: new URLSearchParams({
222
+ grant_type: "authorization_code",
223
+ client_id: clientId,
224
+ client_secret: clientSecret,
225
+ redirect_uri: OOB_REDIRECT,
226
+ code
227
+ })
228
+ });
229
+ if (!tokenRes.ok) {
230
+ const body = await tokenRes.text();
231
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
232
+ }
233
+ const data = await tokenRes.json();
234
+ const tokens = {
235
+ access_token: data.access_token,
236
+ refresh_token: data.refresh_token,
237
+ expires_at: Date.now() + (data.expires_in ?? 3600) * 1e3,
238
+ client_id: clientId,
239
+ client_secret: clientSecret
240
+ };
241
+ saveTokens(tokens);
242
+ return tokens;
243
+ }
244
+ async function asanaFetch(path5, token, options = {}) {
245
+ const url = `${API_BASE}${path5}`;
246
+ const res = await fetch(url, {
247
+ ...options,
248
+ headers: {
249
+ Authorization: `Bearer ${token}`,
250
+ "Content-Type": "application/json",
251
+ ...options.headers
252
+ }
253
+ });
254
+ if (!res.ok) {
255
+ const body = await res.text();
256
+ throw new Error(`Asana API ${res.status}: ${body}`);
257
+ }
258
+ const json = await res.json();
259
+ return json.data;
260
+ }
261
+ async function asanaFetchAll(path5, token) {
262
+ const results = [];
263
+ let nextPage = path5;
264
+ while (nextPage) {
265
+ const url = `${API_BASE}${nextPage}`;
266
+ const res = await fetch(url, {
267
+ headers: { Authorization: `Bearer ${token}` }
268
+ });
269
+ if (!res.ok) {
270
+ const body = await res.text();
271
+ throw new Error(`Asana API ${res.status}: ${body}`);
272
+ }
273
+ const json = await res.json();
274
+ results.push(...json.data);
275
+ if (json.next_page?.path) {
276
+ nextPage = json.next_page.path;
277
+ } else {
278
+ nextPage = null;
279
+ }
280
+ }
281
+ return results;
282
+ }
283
+ async function prompt(rl, question) {
284
+ return rl.question(question);
285
+ }
286
+ async function pickOne(rl, label, items) {
287
+ console.log(`
288
+ ${label}:`);
289
+ for (let i = 0; i < items.length; i++) {
290
+ console.log(` ${i + 1}) ${items[i].name}`);
291
+ }
292
+ while (true) {
293
+ const answer = await prompt(rl, `Choose (1-${items.length}): `);
294
+ const idx = parseInt(answer, 10) - 1;
295
+ if (idx >= 0 && idx < items.length) return items[idx];
296
+ console.log(" Invalid choice, try again.");
297
+ }
298
+ }
299
+ var AsanaAdapter = class {
300
+ name = "asana";
301
+ config;
302
+ accessToken = null;
303
+ /**
304
+ * Resolve the access token based on auth config.
305
+ * - "oauth": load from token file, refresh if needed
306
+ * - "env:VAR": read from environment
307
+ * - anything else: treat as raw PAT
308
+ */
309
+ async resolveToken() {
310
+ const auth = this.config.auth;
311
+ if (auth === "oauth") {
312
+ const tokens = loadTokens();
313
+ if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
314
+ this.accessToken = await refreshAccessToken(tokens);
315
+ return this.accessToken;
316
+ }
317
+ if (this.accessToken) return this.accessToken;
318
+ if (auth.startsWith("env:")) {
319
+ const envVar = auth.slice(4);
320
+ const val = process.env[envVar];
321
+ if (!val) throw new Error(`Environment variable ${envVar} not set`);
322
+ this.accessToken = val;
323
+ return val;
324
+ }
325
+ this.accessToken = auth;
326
+ return auth;
327
+ }
328
+ defaultConfig(_cwd) {
329
+ return {
330
+ auth: "env:ASANA_TOKEN",
331
+ workspace_gid: "",
332
+ project_gid: "",
333
+ project_name: "",
334
+ sections: [],
335
+ done_sections: []
336
+ };
337
+ }
338
+ async setup(_cwd) {
339
+ const rl = createInterface({ input: stdin, output: stdout });
340
+ try {
341
+ console.log("\n\u2500\u2500 Asana Adapter Setup \u2500\u2500\n");
342
+ console.log("Authentication:");
343
+ console.log(" 1) OAuth (browser login)");
344
+ console.log(" 2) Personal Access Token");
345
+ console.log(" 3) Environment variable");
346
+ const authChoice = await prompt(rl, "\nChoose (1-3): ");
347
+ let authConfig;
348
+ let token;
349
+ if (authChoice.trim() === "1") {
350
+ const clientId = (await prompt(rl, "\nAsana App Client ID: ")).trim();
351
+ const clientSecret = (await prompt(rl, "Asana App Client Secret: ")).trim();
352
+ if (!clientId || !clientSecret) {
353
+ console.error("\n Client ID and Secret are required.");
354
+ console.error(" Create an app at: https://app.asana.com/0/my-apps");
355
+ console.error(' Check "This is a native or command-line app"');
356
+ rl.close();
357
+ process.exit(1);
358
+ }
359
+ const tokens = await oauthLogin(clientId, clientSecret, rl);
360
+ token = tokens.access_token;
361
+ authConfig = "oauth";
362
+ } else if (authChoice.trim() === "3") {
363
+ const envVar = await prompt(rl, "Environment variable name [ASANA_TOKEN]: ");
364
+ const varName = envVar.trim() || "ASANA_TOKEN";
365
+ authConfig = `env:${varName}`;
366
+ token = process.env[varName] ?? "";
367
+ if (!token) {
368
+ console.log(`
369
+ Warning: ${varName} is not currently set.`);
370
+ const tempToken = await prompt(rl, " Paste a temporary token for setup (or Enter to skip): ");
371
+ token = tempToken.trim();
372
+ if (!token) {
373
+ console.error(`
374
+ Cannot complete setup without a valid token.`);
375
+ console.error(` Set ${varName} in your environment and try again.`);
376
+ rl.close();
377
+ process.exit(1);
378
+ }
379
+ }
380
+ } else {
381
+ token = (await prompt(rl, "Paste your Personal Access Token: ")).trim();
382
+ authConfig = token;
383
+ }
384
+ console.log("\nVerifying...");
385
+ let me;
386
+ try {
387
+ me = await asanaFetch("/users/me", token);
388
+ } catch (err) {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ console.error(` Authentication failed: ${msg}`);
391
+ rl.close();
392
+ process.exit(1);
393
+ }
394
+ console.log(` Authenticated as: ${me.name} (${me.email})`);
395
+ const workspaces = await asanaFetch("/workspaces", token);
396
+ let workspace;
397
+ if (workspaces.length === 1) {
398
+ workspace = workspaces[0];
399
+ console.log(`
400
+ Workspace: ${workspace.name}`);
401
+ } else {
402
+ workspace = await pickOne(rl, "Select workspace", workspaces);
403
+ }
404
+ const projects = await asanaFetchAll(
405
+ `/workspaces/${workspace.gid}/projects?limit=100&archived=false`,
406
+ token
407
+ );
408
+ if (projects.length === 0) {
409
+ console.error("\n No projects found in this workspace.");
410
+ rl.close();
411
+ process.exit(1);
412
+ }
413
+ const project = await pickOne(rl, "Select project", projects);
414
+ const sections = await asanaFetch(
415
+ `/projects/${project.gid}/sections`,
416
+ token
417
+ );
418
+ console.log(`
419
+ Sections in "${project.name}":`);
420
+ console.log("Map each section to a Taskwarrior tag (or press Enter for default).\n");
421
+ const sectionMappings = [];
422
+ const doneSections = [];
423
+ for (const section of sections) {
424
+ const defaultTag = section.name.toLowerCase().replace(/\s+/g, "_");
425
+ const tag = await prompt(rl, ` "${section.name}" \u2192 tag [${defaultTag}]: `);
426
+ const finalTag = tag.trim() || defaultTag;
427
+ sectionMappings.push({
428
+ gid: section.gid,
429
+ name: section.name,
430
+ tag: finalTag
431
+ });
432
+ }
433
+ console.log("\nWhich sections represent completed tasks?");
434
+ for (let i = 0; i < sectionMappings.length; i++) {
435
+ console.log(` ${i + 1}) ${sectionMappings[i].name} (${sectionMappings[i].tag})`);
436
+ }
437
+ const doneAnswer = await prompt(rl, "Enter numbers separated by commas (e.g., 4,5): ");
438
+ for (const num of doneAnswer.split(",")) {
439
+ const idx = parseInt(num.trim(), 10) - 1;
440
+ if (idx >= 0 && idx < sectionMappings.length) {
441
+ doneSections.push(sectionMappings[idx].gid);
442
+ }
443
+ }
444
+ console.log("\nWhich section should tasks move to on `task start`?");
445
+ for (let i = 0; i < sectionMappings.length; i++) {
446
+ console.log(` ${i + 1}) ${sectionMappings[i].name}`);
447
+ }
448
+ console.log(` 0) Don't move (no action on start)`);
449
+ const startAnswer = await prompt(rl, "Choose: ");
450
+ const startIdx = parseInt(startAnswer.trim(), 10) - 1;
451
+ const startSectionGid = startIdx >= 0 && startIdx < sectionMappings.length ? sectionMappings[startIdx].gid : void 0;
452
+ rl.close();
453
+ console.log(`
454
+ Project: ${project.name}`);
455
+ console.log(` Sections: ${sectionMappings.map((s) => s.tag).join(", ")}`);
456
+ console.log(` Done: ${doneSections.length} section(s)`);
457
+ if (startSectionGid) {
458
+ const startSection = sectionMappings.find((s) => s.gid === startSectionGid);
459
+ console.log(` On start: move to "${startSection?.name}"`);
460
+ }
461
+ return {
462
+ auth: authConfig,
463
+ workspace_gid: workspace.gid,
464
+ project_gid: project.gid,
465
+ project_name: project.name,
466
+ sections: sectionMappings,
467
+ done_sections: doneSections,
468
+ start_section_gid: startSectionGid
469
+ };
470
+ } catch (err) {
471
+ rl.close();
472
+ throw err;
473
+ }
474
+ }
475
+ async init(config) {
476
+ this.config = config;
477
+ if (!this.config.project_gid) {
478
+ throw new Error('asana adapter requires "project_gid" in config');
479
+ }
480
+ }
481
+ async pull() {
482
+ const token = await this.resolveToken();
483
+ const fields = [
484
+ "gid",
485
+ "name",
486
+ "completed",
487
+ "completed_at",
488
+ "assignee",
489
+ "assignee.name",
490
+ "memberships.project",
491
+ "memberships.project.name",
492
+ "memberships.section",
493
+ "memberships.section.name",
494
+ "permalink_url",
495
+ "custom_fields",
496
+ "custom_fields.name",
497
+ "custom_fields.display_value"
498
+ ].join(",");
499
+ const asanaTasks = await asanaFetchAll(
500
+ `/projects/${this.config.project_gid}/tasks?opt_fields=${fields}&limit=100`,
501
+ token
502
+ );
503
+ const tasks = [];
504
+ const sectionMap = new Map(
505
+ this.config.sections.map((s) => [s.gid, s])
506
+ );
507
+ for (const at of asanaTasks) {
508
+ if (at.completed) continue;
509
+ const membership = at.memberships?.find(
510
+ (m) => m.project.gid === this.config.project_gid
511
+ );
512
+ const sectionGid = membership?.section?.gid;
513
+ const sectionMapping = sectionGid ? sectionMap.get(sectionGid) : null;
514
+ const tags = [];
515
+ if (sectionMapping) tags.push(sectionMapping.tag);
516
+ const priority = mapAsanaPriority(at.custom_fields);
517
+ const task = {
518
+ uuid: "",
519
+ description: at.name,
520
+ status: "pending",
521
+ entry: (/* @__PURE__ */ new Date()).toISOString(),
522
+ project: this.config.project_name,
523
+ tags,
524
+ priority,
525
+ backend: "",
526
+ backend_id: at.gid,
527
+ annotations: at.permalink_url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: at.permalink_url }] : void 0
528
+ };
529
+ tasks.push(task);
530
+ }
531
+ return tasks;
532
+ }
533
+ async onStart(task, _ttyFd) {
534
+ if (!this.config.start_section_gid) return;
535
+ const taskGid = task.backend_id;
536
+ if (!taskGid) return;
537
+ const token = await this.resolveToken();
538
+ const startSection = this.config.sections.find(
539
+ (s) => s.gid === this.config.start_section_gid
540
+ );
541
+ process.stderr.write(
542
+ `tw-bridge [asana]: moving task to "${startSection?.name ?? "In Progress"}"
543
+ `
544
+ );
545
+ await asanaFetch(`/sections/${this.config.start_section_gid}/addTask`, token, {
546
+ method: "POST",
547
+ body: JSON.stringify({ data: { task: taskGid } })
548
+ });
549
+ }
550
+ async onDone(task) {
551
+ const taskGid = task.backend_id;
552
+ if (!taskGid) return;
553
+ const token = await this.resolveToken();
554
+ process.stderr.write(`tw-bridge [asana]: marking task complete
555
+ `);
556
+ await asanaFetch(`/tasks/${taskGid}`, token, {
557
+ method: "PUT",
558
+ body: JSON.stringify({ data: { completed: true } })
559
+ });
560
+ }
561
+ };
562
+ function mapAsanaPriority(fields) {
563
+ if (!fields) return void 0;
564
+ const pField = fields.find(
565
+ (f) => f.name.toLowerCase() === "priority" && f.display_value
566
+ );
567
+ if (!pField?.display_value) return void 0;
568
+ const val = pField.display_value.toLowerCase();
569
+ if (val.startsWith("high") || val === "urgent" || val === "p0" || val === "p1") return "H";
570
+ if (val.startsWith("med") || val === "p2") return "M";
571
+ if (val.startsWith("low") || val === "p3" || val === "p4") return "L";
572
+ return void 0;
573
+ }
574
+
153
575
  // src/registry.ts
154
576
  var BUILTIN_ADAPTERS = {
155
- ghp: () => new GhpAdapter()
577
+ ghp: () => new GhpAdapter(),
578
+ asana: () => new AsanaAdapter()
156
579
  };
157
580
  function matchBackend(task, config) {
158
581
  if (task.backend && config.backends[task.backend]) {
@@ -285,7 +708,7 @@ function updateTaskDescription(existing, newDescription) {
285
708
  }
286
709
 
287
710
  // src/cli.ts
288
- var HOOKS_DIR = path3.join(os2.homedir(), ".task", "hooks");
711
+ var HOOKS_DIR = path4.join(os3.homedir(), ".task", "hooks");
289
712
  var commands = {
290
713
  add: addBackend,
291
714
  install,
@@ -346,14 +769,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
346
769
  }
347
770
  const matchTag = tagOverride ?? name;
348
771
  const cwd = process.cwd();
349
- const adapterConfig = adapter.defaultConfig(cwd);
772
+ const adapterConfig = adapter.setup ? await adapter.setup(cwd) : adapter.defaultConfig(cwd);
773
+ let doneStatuses;
774
+ const sections = adapterConfig.sections;
775
+ const doneSectionGids = adapterConfig.done_sections;
776
+ if (sections?.length && doneSectionGids?.length) {
777
+ const gidSet = new Set(doneSectionGids);
778
+ doneStatuses = sections.filter((s) => gidSet.has(s.gid)).map((s) => s.tag);
779
+ }
350
780
  config.backends[name] = {
351
781
  adapter: adapterType,
352
782
  match: { tags: [matchTag] },
783
+ ...doneStatuses?.length && { done_statuses: doneStatuses },
353
784
  config: adapterConfig
354
785
  };
355
786
  const configPath = saveConfig(config);
356
- console.log(`Added backend "${name}" (adapter: ${adapterType})`);
787
+ console.log(`
788
+ Added backend "${name}" (adapter: ${adapterType})`);
357
789
  console.log(`Config: ${configPath}`);
358
790
  console.log(`Match tag: +${matchTag}`);
359
791
  if (adapterConfig.cwd) {
@@ -368,18 +800,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
368
800
  Use 'task context ${matchTag}' to switch to this project.`);
369
801
  }
370
802
  async function install() {
371
- fs2.mkdirSync(HOOKS_DIR, { recursive: true });
372
- const hookSource = path3.resolve(
373
- path3.dirname(new URL(import.meta.url).pathname),
803
+ fs3.mkdirSync(HOOKS_DIR, { recursive: true });
804
+ const hookSource = path4.resolve(
805
+ path4.dirname(new URL(import.meta.url).pathname),
374
806
  "hooks",
375
807
  "on-modify.js"
376
808
  );
377
- const hookTarget = path3.join(HOOKS_DIR, "on-modify.tw-bridge");
378
- if (fs2.existsSync(hookTarget)) {
379
- fs2.unlinkSync(hookTarget);
809
+ const hookTarget = path4.join(HOOKS_DIR, "on-modify.tw-bridge");
810
+ if (fs3.existsSync(hookTarget)) {
811
+ fs3.unlinkSync(hookTarget);
380
812
  }
381
- fs2.symlinkSync(hookSource, hookTarget);
382
- fs2.chmodSync(hookSource, 493);
813
+ fs3.symlinkSync(hookSource, hookTarget);
814
+ fs3.chmodSync(hookSource, 493);
383
815
  console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
384
816
  console.log("\nAdd these to your .taskrc:\n");
385
817
  console.log("# --- tw-bridge UDAs ---");
@@ -395,22 +827,43 @@ async function install() {
395
827
  console.log("urgency.user.tag.in_review.coefficient=-2.0");
396
828
  console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
397
829
  console.log("urgency.user.tag.in_beta.coefficient=-6.0");
398
- if (fs2.existsSync(STANDARD_TIMEW_HOOK)) {
830
+ installTimewExtension();
831
+ if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
399
832
  console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
400
- console.log(" tw-bridge timewarrior enable-overlaps");
833
+ console.log(" tw-bridge timewarrior enable");
401
834
  }
402
835
  installShellFunction();
403
836
  }
404
- var STANDARD_TIMEW_HOOK = path3.join(HOOKS_DIR, "on-modify.timewarrior");
837
+ var TIMEW_EXT_DIR = path4.join(os3.homedir(), ".timewarrior", "extensions");
838
+ function installTimewExtension() {
839
+ fs3.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
840
+ const extSource = path4.resolve(
841
+ path4.dirname(new URL(import.meta.url).pathname),
842
+ "extensions",
843
+ "bridge.js"
844
+ );
845
+ const extTarget = path4.join(TIMEW_EXT_DIR, "bridge");
846
+ if (fs3.existsSync(extTarget)) {
847
+ fs3.unlinkSync(extTarget);
848
+ }
849
+ fs3.symlinkSync(extSource, extTarget);
850
+ fs3.chmodSync(extSource, 493);
851
+ console.log(`
852
+ Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
853
+ console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
854
+ }
855
+ var STANDARD_TIMEW_HOOK = path4.join(HOOKS_DIR, "on-modify.timewarrior");
405
856
  async function timewarriorCmd() {
406
857
  const sub = process.argv[3];
407
858
  if (!sub || sub === "--help") {
408
859
  console.log("Usage: tw-bridge timewarrior <subcommand>\n");
409
860
  console.log("Subcommands:");
410
- console.log(" enable Enable Timewarrior tracking");
411
- console.log(" enable-overlaps Enable with overlapping intervals (for parallel work)");
412
- console.log(" disable Disable Timewarrior tracking");
413
- console.log(" status Show current Timewarrior configuration");
861
+ console.log(" enable Enable Timewarrior tracking");
862
+ console.log(" disable Disable Timewarrior tracking");
863
+ console.log(" status Show current Timewarrior configuration");
864
+ console.log("\nParallel time tracking is controlled per-invocation:");
865
+ console.log(" task start <id> --parallel Track in parallel with current task");
866
+ console.log(" task start <id> --switch Stop current task, start new one");
414
867
  return;
415
868
  }
416
869
  const config = loadConfig();
@@ -419,10 +872,11 @@ async function timewarriorCmd() {
419
872
  if (!tw?.enabled) {
420
873
  console.log("Timewarrior: disabled");
421
874
  } else {
422
- console.log(`Timewarrior: enabled (overlaps: ${tw.allow_overlaps ? "yes" : "no"})`);
875
+ console.log("Timewarrior: enabled");
876
+ console.log(" Parallel tracking: use `task start <id> --parallel`");
423
877
  }
424
- const hookExists = fs2.existsSync(STANDARD_TIMEW_HOOK);
425
- const hookDisabled = fs2.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
878
+ const hookExists = fs3.existsSync(STANDARD_TIMEW_HOOK);
879
+ const hookDisabled = fs3.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
426
880
  if (hookExists) {
427
881
  console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
428
882
  if (tw?.enabled) {
@@ -435,18 +889,14 @@ async function timewarriorCmd() {
435
889
  }
436
890
  return;
437
891
  }
438
- if (sub === "enable" || sub === "enable-overlaps") {
439
- const allowOverlaps = sub === "enable-overlaps";
440
- config.timewarrior = {
441
- enabled: true,
442
- allow_overlaps: allowOverlaps
443
- };
892
+ if (sub === "enable") {
893
+ config.timewarrior = { enabled: true };
444
894
  const configPath = saveConfig(config);
445
- console.log(`Timewarrior tracking enabled (overlaps: ${allowOverlaps ? "yes" : "no"})`);
895
+ console.log("Timewarrior tracking enabled");
446
896
  console.log(`Config: ${configPath}`);
447
- if (fs2.existsSync(STANDARD_TIMEW_HOOK)) {
897
+ if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
448
898
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
449
- fs2.renameSync(STANDARD_TIMEW_HOOK, disabled);
899
+ fs3.renameSync(STANDARD_TIMEW_HOOK, disabled);
450
900
  console.log(`
451
901
  Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
452
902
  console.log("tw-bridge will handle Timewarrior tracking directly.");
@@ -454,16 +904,13 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
454
904
  return;
455
905
  }
456
906
  if (sub === "disable") {
457
- config.timewarrior = {
458
- enabled: false,
459
- allow_overlaps: false
460
- };
907
+ config.timewarrior = { enabled: false };
461
908
  const configPath = saveConfig(config);
462
909
  console.log("Timewarrior tracking disabled");
463
910
  console.log(`Config: ${configPath}`);
464
911
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
465
- if (fs2.existsSync(disabled)) {
466
- fs2.renameSync(disabled, STANDARD_TIMEW_HOOK);
912
+ if (fs3.existsSync(disabled)) {
913
+ fs3.renameSync(disabled, STANDARD_TIMEW_HOOK);
467
914
  console.log(`
468
915
  Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
469
916
  }
@@ -475,40 +922,46 @@ Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
475
922
  var SHELL_FUNCTION = `
476
923
  # tw-bridge: auto-context task wrapper
477
924
  task() {
478
- local ctx
925
+ local ctx mode=""
926
+ local args=()
927
+
928
+ for arg in "$@"; do
929
+ case "$arg" in
930
+ --parallel|-p) mode="parallel" ;;
931
+ --switch|-s) mode="switch" ;;
932
+ *) args+=("$arg") ;;
933
+ esac
934
+ done
935
+
479
936
  ctx=$(tw-bridge which 2>/dev/null)
480
- if [ -n "$ctx" ]; then
481
- command task "rc.context=$ctx" "$@"
482
- else
483
- command task "$@"
484
- fi
937
+ TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
485
938
  }
486
939
  `.trim();
487
940
  var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
488
941
  function installShellFunction() {
489
942
  const shell = process.env.SHELL ?? "/bin/bash";
490
- const home = os2.homedir();
943
+ const home = os3.homedir();
491
944
  let rcFile;
492
945
  if (shell.endsWith("zsh")) {
493
- rcFile = path3.join(home, ".zshrc");
946
+ rcFile = path4.join(home, ".zshrc");
494
947
  } else {
495
- rcFile = path3.join(home, ".bashrc");
948
+ rcFile = path4.join(home, ".bashrc");
496
949
  }
497
- const existing = fs2.existsSync(rcFile) ? fs2.readFileSync(rcFile, "utf-8") : "";
950
+ const existing = fs3.existsSync(rcFile) ? fs3.readFileSync(rcFile, "utf-8") : "";
498
951
  if (existing.includes(SHELL_MARKER)) {
499
952
  console.log(`
500
953
  Shell integration already installed in ${rcFile}`);
501
954
  return;
502
955
  }
503
- fs2.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
956
+ fs3.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
504
957
  console.log(`
505
958
  Shell integration installed in ${rcFile}`);
506
959
  console.log("Restart your shell or run: source " + rcFile);
507
960
  }
508
- var SEEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", ".seen-dirs");
961
+ var SEEN_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", ".seen-dirs");
509
962
  function loadSeenDirs() {
510
963
  try {
511
- const raw = fs2.readFileSync(SEEN_FILE, "utf-8");
964
+ const raw = fs3.readFileSync(SEEN_FILE, "utf-8");
512
965
  return new Set(raw.split("\n").filter(Boolean));
513
966
  } catch {
514
967
  return /* @__PURE__ */ new Set();
@@ -518,15 +971,15 @@ function markDirSeen(dir) {
518
971
  const seen = loadSeenDirs();
519
972
  if (seen.has(dir)) return;
520
973
  seen.add(dir);
521
- fs2.mkdirSync(path3.dirname(SEEN_FILE), { recursive: true });
522
- fs2.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
974
+ fs3.mkdirSync(path4.dirname(SEEN_FILE), { recursive: true });
975
+ fs3.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
523
976
  }
524
977
  function isGitRepo(dir) {
525
978
  try {
526
979
  let current = dir;
527
- while (current !== path3.dirname(current)) {
528
- if (fs2.existsSync(path3.join(current, ".git"))) return true;
529
- current = path3.dirname(current);
980
+ while (current !== path4.dirname(current)) {
981
+ if (fs3.existsSync(path4.join(current, ".git"))) return true;
982
+ current = path4.dirname(current);
530
983
  }
531
984
  return false;
532
985
  } catch {
@@ -539,8 +992,8 @@ async function which() {
539
992
  for (const [_name, backend] of Object.entries(config.backends)) {
540
993
  const backendCwd = backend.config?.cwd;
541
994
  if (!backendCwd) continue;
542
- const resolved = path3.resolve(backendCwd);
543
- if (cwd === resolved || cwd.startsWith(resolved + path3.sep)) {
995
+ const resolved = path4.resolve(backendCwd);
996
+ if (cwd === resolved || cwd.startsWith(resolved + path4.sep)) {
544
997
  const contextTag = backend.match.tags?.[0];
545
998
  if (contextTag) {
546
999
  process.stdout.write(contextTag);
@@ -556,15 +1009,15 @@ async function which() {
556
1009
  const seen = loadSeenDirs();
557
1010
  let gitRoot = cwd;
558
1011
  let current = cwd;
559
- while (current !== path3.dirname(current)) {
560
- if (fs2.existsSync(path3.join(current, ".git"))) {
1012
+ while (current !== path4.dirname(current)) {
1013
+ if (fs3.existsSync(path4.join(current, ".git"))) {
561
1014
  gitRoot = current;
562
1015
  break;
563
1016
  }
564
- current = path3.dirname(current);
1017
+ current = path4.dirname(current);
565
1018
  }
566
1019
  if (!seen.has(gitRoot)) {
567
- const dirName = path3.basename(gitRoot);
1020
+ const dirName = path4.basename(gitRoot);
568
1021
  process.stderr.write(
569
1022
  `tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
570
1023
  `
@@ -628,6 +1081,14 @@ async function syncBackend(name, backend, config) {
628
1081
  }
629
1082
  } else if (isDone) {
630
1083
  if (completeTask(existingTask, matchTags)) {
1084
+ if (adapter.onDone) {
1085
+ try {
1086
+ await adapter.onDone(task);
1087
+ } catch (err) {
1088
+ const msg = err instanceof Error ? err.message : String(err);
1089
+ console.error(` \u26A0 [#${backendId}] failed to push completion: ${msg}`);
1090
+ }
1091
+ }
631
1092
  console.log(` \u2713 [#${backendId}] ${task.description}`);
632
1093
  completed++;
633
1094
  }