@bretwardjames/tw-bridge 0.3.0 → 0.5.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 fs4 from "fs";
5
+ import path5 from "path";
6
+ import os4 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(path6, token, options = {}) {
245
+ const url = `${API_BASE}${path6}`;
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(path6, token) {
262
+ const results = [];
263
+ let nextPage = path6;
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]) {
@@ -284,12 +707,65 @@ function updateTaskDescription(existing, newDescription) {
284
707
  return result.status === 0;
285
708
  }
286
709
 
710
+ // src/tracking.ts
711
+ import fs3 from "fs";
712
+ import path4 from "path";
713
+ import os3 from "os";
714
+ import { spawnSync as spawnSync3 } from "child_process";
715
+ var TRACKING_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", "tracking.json");
716
+ function loadTracking() {
717
+ try {
718
+ return JSON.parse(fs3.readFileSync(TRACKING_FILE, "utf-8"));
719
+ } catch {
720
+ return {};
721
+ }
722
+ }
723
+ function saveTracking(state) {
724
+ fs3.mkdirSync(path4.dirname(TRACKING_FILE), { recursive: true });
725
+ fs3.writeFileSync(TRACKING_FILE, JSON.stringify(state));
726
+ }
727
+ function mergedTags(state) {
728
+ const all = /* @__PURE__ */ new Set();
729
+ for (const tags of Object.values(state)) {
730
+ for (const tag of tags) all.add(tag);
731
+ }
732
+ return [...all];
733
+ }
734
+ function startParallel(key, tags) {
735
+ const tracking = loadTracking();
736
+ tracking[key] = tags;
737
+ saveTracking(tracking);
738
+ const merged = mergedTags(tracking);
739
+ spawnSync3("timew", ["stop"], { stdio: "pipe" });
740
+ spawnSync3("timew", ["start", ...merged], { stdio: "pipe" });
741
+ }
742
+ function startSwitch(key, tags) {
743
+ saveTracking({ [key]: tags });
744
+ spawnSync3("timew", ["stop"], { stdio: "pipe" });
745
+ spawnSync3("timew", ["start", ...tags], { stdio: "pipe" });
746
+ }
747
+ function stopEntry(key) {
748
+ const tracking = loadTracking();
749
+ delete tracking[key];
750
+ saveTracking(tracking);
751
+ const remaining = mergedTags(tracking);
752
+ spawnSync3("timew", ["stop"], { stdio: "pipe" });
753
+ if (remaining.length > 0) {
754
+ spawnSync3("timew", ["start", ...remaining], { stdio: "pipe" });
755
+ }
756
+ }
757
+ function getActiveMeetings() {
758
+ const tracking = loadTracking();
759
+ return Object.entries(tracking).filter(([key]) => key.startsWith("meeting:")).map(([key, tags]) => ({ key, tags }));
760
+ }
761
+
287
762
  // src/cli.ts
288
- var HOOKS_DIR = path3.join(os2.homedir(), ".task", "hooks");
763
+ var HOOKS_DIR = path5.join(os4.homedir(), ".task", "hooks");
289
764
  var commands = {
290
765
  add: addBackend,
291
766
  install,
292
767
  sync,
768
+ meeting: meetingCmd,
293
769
  timewarrior: timewarriorCmd,
294
770
  which,
295
771
  config: showConfig
@@ -299,9 +775,10 @@ async function main() {
299
775
  if (!command || command === "--help") {
300
776
  console.log("Usage: tw-bridge <command>\n");
301
777
  console.log("Commands:");
302
- console.log(" add Add a new backend instance");
303
- console.log(" install Install Taskwarrior hooks and shell integration");
778
+ console.log(" add Add a new backend instance");
779
+ console.log(" install Install Taskwarrior hooks and shell integration");
304
780
  console.log(" sync Pull tasks from all backends");
781
+ console.log(" meeting Track meetings in Timewarrior (no task created)");
305
782
  console.log(" timewarrior Manage Timewarrior integration");
306
783
  console.log(" which Print the context for the current directory");
307
784
  console.log(" config Show current configuration");
@@ -346,14 +823,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
346
823
  }
347
824
  const matchTag = tagOverride ?? name;
348
825
  const cwd = process.cwd();
349
- const adapterConfig = adapter.defaultConfig(cwd);
826
+ const adapterConfig = adapter.setup ? await adapter.setup(cwd) : adapter.defaultConfig(cwd);
827
+ let doneStatuses;
828
+ const sections = adapterConfig.sections;
829
+ const doneSectionGids = adapterConfig.done_sections;
830
+ if (sections?.length && doneSectionGids?.length) {
831
+ const gidSet = new Set(doneSectionGids);
832
+ doneStatuses = sections.filter((s) => gidSet.has(s.gid)).map((s) => s.tag);
833
+ }
350
834
  config.backends[name] = {
351
835
  adapter: adapterType,
352
836
  match: { tags: [matchTag] },
837
+ ...doneStatuses?.length && { done_statuses: doneStatuses },
353
838
  config: adapterConfig
354
839
  };
355
840
  const configPath = saveConfig(config);
356
- console.log(`Added backend "${name}" (adapter: ${adapterType})`);
841
+ console.log(`
842
+ Added backend "${name}" (adapter: ${adapterType})`);
357
843
  console.log(`Config: ${configPath}`);
358
844
  console.log(`Match tag: +${matchTag}`);
359
845
  if (adapterConfig.cwd) {
@@ -368,18 +854,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
368
854
  Use 'task context ${matchTag}' to switch to this project.`);
369
855
  }
370
856
  async function install() {
371
- fs2.mkdirSync(HOOKS_DIR, { recursive: true });
372
- const hookSource = path3.resolve(
373
- path3.dirname(new URL(import.meta.url).pathname),
857
+ fs4.mkdirSync(HOOKS_DIR, { recursive: true });
858
+ const hookSource = path5.resolve(
859
+ path5.dirname(new URL(import.meta.url).pathname),
374
860
  "hooks",
375
861
  "on-modify.js"
376
862
  );
377
- const hookTarget = path3.join(HOOKS_DIR, "on-modify.tw-bridge");
378
- if (fs2.existsSync(hookTarget)) {
379
- fs2.unlinkSync(hookTarget);
863
+ const hookTarget = path5.join(HOOKS_DIR, "on-modify.tw-bridge");
864
+ if (fs4.existsSync(hookTarget)) {
865
+ fs4.unlinkSync(hookTarget);
380
866
  }
381
- fs2.symlinkSync(hookSource, hookTarget);
382
- fs2.chmodSync(hookSource, 493);
867
+ fs4.symlinkSync(hookSource, hookTarget);
868
+ fs4.chmodSync(hookSource, 493);
383
869
  console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
384
870
  console.log("\nAdd these to your .taskrc:\n");
385
871
  console.log("# --- tw-bridge UDAs ---");
@@ -395,22 +881,145 @@ async function install() {
395
881
  console.log("urgency.user.tag.in_review.coefficient=-2.0");
396
882
  console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
397
883
  console.log("urgency.user.tag.in_beta.coefficient=-6.0");
398
- if (fs2.existsSync(STANDARD_TIMEW_HOOK)) {
884
+ installTimewExtension();
885
+ if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
399
886
  console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
400
- console.log(" tw-bridge timewarrior enable-overlaps");
887
+ console.log(" tw-bridge timewarrior enable");
401
888
  }
402
889
  installShellFunction();
403
890
  }
404
- var STANDARD_TIMEW_HOOK = path3.join(HOOKS_DIR, "on-modify.timewarrior");
891
+ var TIMEW_EXT_DIR = path5.join(os4.homedir(), ".timewarrior", "extensions");
892
+ function installTimewExtension() {
893
+ fs4.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
894
+ const extSource = path5.resolve(
895
+ path5.dirname(new URL(import.meta.url).pathname),
896
+ "extensions",
897
+ "bridge.js"
898
+ );
899
+ const extTarget = path5.join(TIMEW_EXT_DIR, "bridge");
900
+ if (fs4.existsSync(extTarget)) {
901
+ fs4.unlinkSync(extTarget);
902
+ }
903
+ fs4.symlinkSync(extSource, extTarget);
904
+ fs4.chmodSync(extSource, 493);
905
+ console.log(`
906
+ Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
907
+ console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
908
+ }
909
+ function sanitizeMeetingName(name) {
910
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
911
+ }
912
+ function detectProjectContext() {
913
+ const cwd = process.cwd();
914
+ const config = loadConfig();
915
+ for (const [, backend] of Object.entries(config.backends)) {
916
+ const backendCwd = backend.config?.cwd;
917
+ if (!backendCwd) continue;
918
+ const resolved = path5.resolve(backendCwd);
919
+ if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
920
+ return backend.match.tags?.[0] ?? null;
921
+ }
922
+ }
923
+ return null;
924
+ }
925
+ async function meetingCmd() {
926
+ const sub = process.argv[3];
927
+ if (!sub || sub === "--help") {
928
+ console.log("Usage: tw-bridge meeting <subcommand>\n");
929
+ console.log("Subcommands:");
930
+ console.log(" start <name> [--switch] Start tracking a meeting");
931
+ console.log(" stop [name] Stop a meeting (or all if no name)");
932
+ console.log(" list Show active meetings");
933
+ console.log("\nMeetings are tracked in Timewarrior only \u2014 no Taskwarrior task is created.");
934
+ console.log("By default, meetings run in parallel with active tasks.");
935
+ console.log("Use --switch to pause the current task instead.");
936
+ return;
937
+ }
938
+ const config = loadConfig();
939
+ if (!config.timewarrior?.enabled) {
940
+ console.error("Timewarrior is not enabled. Run: tw-bridge timewarrior enable");
941
+ process.exit(1);
942
+ }
943
+ if (sub === "start") {
944
+ const nameArg = process.argv.slice(4).filter((a) => !a.startsWith("--")).join(" ");
945
+ if (!nameArg) {
946
+ console.error("Usage: tw-bridge meeting start <name>");
947
+ process.exit(1);
948
+ }
949
+ const switchMode = process.argv.includes("--switch") || process.argv.includes("-s");
950
+ const tag = sanitizeMeetingName(nameArg);
951
+ const key = `meeting:${tag}`;
952
+ const tags = ["meeting", tag];
953
+ const project = detectProjectContext();
954
+ if (project) tags.push(project);
955
+ if (switchMode) {
956
+ startSwitch(key, tags);
957
+ } else {
958
+ startParallel(key, tags);
959
+ }
960
+ console.log(`Meeting started: ${nameArg}`);
961
+ console.log(` Tags: ${tags.join(" ")}`);
962
+ if (!switchMode) {
963
+ console.log(" Mode: parallel (active tasks continue tracking)");
964
+ } else {
965
+ console.log(" Mode: switch (active tasks paused)");
966
+ }
967
+ return;
968
+ }
969
+ if (sub === "stop") {
970
+ const nameArg = process.argv.slice(4).join(" ").trim();
971
+ const active = getActiveMeetings();
972
+ if (active.length === 0) {
973
+ console.log("No active meetings.");
974
+ return;
975
+ }
976
+ if (nameArg) {
977
+ const tag = sanitizeMeetingName(nameArg);
978
+ const key = `meeting:${tag}`;
979
+ const match = active.find((m) => m.key === key);
980
+ if (!match) {
981
+ console.error(`No active meeting matching "${nameArg}".`);
982
+ console.error(`Active meetings: ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
983
+ process.exit(1);
984
+ }
985
+ stopEntry(key);
986
+ console.log(`Meeting stopped: ${nameArg}`);
987
+ } else {
988
+ for (const m of active) {
989
+ stopEntry(m.key);
990
+ }
991
+ console.log(`Stopped ${active.length} meeting(s): ${active.map((m) => m.key.replace("meeting:", "")).join(", ")}`);
992
+ }
993
+ return;
994
+ }
995
+ if (sub === "list") {
996
+ const active = getActiveMeetings();
997
+ if (active.length === 0) {
998
+ console.log("No active meetings.");
999
+ return;
1000
+ }
1001
+ console.log("Active meetings:");
1002
+ for (const m of active) {
1003
+ const name = m.key.replace("meeting:", "");
1004
+ console.log(` ${name} (${m.tags.join(" ")})`);
1005
+ }
1006
+ return;
1007
+ }
1008
+ console.error(`Unknown subcommand: ${sub}`);
1009
+ process.exit(1);
1010
+ }
1011
+ var STANDARD_TIMEW_HOOK = path5.join(HOOKS_DIR, "on-modify.timewarrior");
405
1012
  async function timewarriorCmd() {
406
1013
  const sub = process.argv[3];
407
1014
  if (!sub || sub === "--help") {
408
1015
  console.log("Usage: tw-bridge timewarrior <subcommand>\n");
409
1016
  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");
1017
+ console.log(" enable Enable Timewarrior tracking");
1018
+ console.log(" disable Disable Timewarrior tracking");
1019
+ console.log(" status Show current Timewarrior configuration");
1020
+ console.log("\nParallel time tracking is controlled per-invocation:");
1021
+ console.log(" task start <id> --parallel Track in parallel with current task");
1022
+ console.log(" task start <id> --switch Stop current task, start new one");
414
1023
  return;
415
1024
  }
416
1025
  const config = loadConfig();
@@ -419,10 +1028,11 @@ async function timewarriorCmd() {
419
1028
  if (!tw?.enabled) {
420
1029
  console.log("Timewarrior: disabled");
421
1030
  } else {
422
- console.log(`Timewarrior: enabled (overlaps: ${tw.allow_overlaps ? "yes" : "no"})`);
1031
+ console.log("Timewarrior: enabled");
1032
+ console.log(" Parallel tracking: use `task start <id> --parallel`");
423
1033
  }
424
- const hookExists = fs2.existsSync(STANDARD_TIMEW_HOOK);
425
- const hookDisabled = fs2.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
1034
+ const hookExists = fs4.existsSync(STANDARD_TIMEW_HOOK);
1035
+ const hookDisabled = fs4.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
426
1036
  if (hookExists) {
427
1037
  console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
428
1038
  if (tw?.enabled) {
@@ -435,18 +1045,14 @@ async function timewarriorCmd() {
435
1045
  }
436
1046
  return;
437
1047
  }
438
- if (sub === "enable" || sub === "enable-overlaps") {
439
- const allowOverlaps = sub === "enable-overlaps";
440
- config.timewarrior = {
441
- enabled: true,
442
- allow_overlaps: allowOverlaps
443
- };
1048
+ if (sub === "enable") {
1049
+ config.timewarrior = { enabled: true };
444
1050
  const configPath = saveConfig(config);
445
- console.log(`Timewarrior tracking enabled (overlaps: ${allowOverlaps ? "yes" : "no"})`);
1051
+ console.log("Timewarrior tracking enabled");
446
1052
  console.log(`Config: ${configPath}`);
447
- if (fs2.existsSync(STANDARD_TIMEW_HOOK)) {
1053
+ if (fs4.existsSync(STANDARD_TIMEW_HOOK)) {
448
1054
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
449
- fs2.renameSync(STANDARD_TIMEW_HOOK, disabled);
1055
+ fs4.renameSync(STANDARD_TIMEW_HOOK, disabled);
450
1056
  console.log(`
451
1057
  Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
452
1058
  console.log("tw-bridge will handle Timewarrior tracking directly.");
@@ -454,16 +1060,13 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
454
1060
  return;
455
1061
  }
456
1062
  if (sub === "disable") {
457
- config.timewarrior = {
458
- enabled: false,
459
- allow_overlaps: false
460
- };
1063
+ config.timewarrior = { enabled: false };
461
1064
  const configPath = saveConfig(config);
462
1065
  console.log("Timewarrior tracking disabled");
463
1066
  console.log(`Config: ${configPath}`);
464
1067
  const disabled = STANDARD_TIMEW_HOOK + ".disabled";
465
- if (fs2.existsSync(disabled)) {
466
- fs2.renameSync(disabled, STANDARD_TIMEW_HOOK);
1068
+ if (fs4.existsSync(disabled)) {
1069
+ fs4.renameSync(disabled, STANDARD_TIMEW_HOOK);
467
1070
  console.log(`
468
1071
  Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
469
1072
  }
@@ -475,40 +1078,46 @@ Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
475
1078
  var SHELL_FUNCTION = `
476
1079
  # tw-bridge: auto-context task wrapper
477
1080
  task() {
478
- local ctx
1081
+ local ctx mode=""
1082
+ local args=()
1083
+
1084
+ for arg in "$@"; do
1085
+ case "$arg" in
1086
+ --parallel|-p) mode="parallel" ;;
1087
+ --switch|-s) mode="switch" ;;
1088
+ *) args+=("$arg") ;;
1089
+ esac
1090
+ done
1091
+
479
1092
  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
1093
+ TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
485
1094
  }
486
1095
  `.trim();
487
1096
  var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
488
1097
  function installShellFunction() {
489
1098
  const shell = process.env.SHELL ?? "/bin/bash";
490
- const home = os2.homedir();
1099
+ const home = os4.homedir();
491
1100
  let rcFile;
492
1101
  if (shell.endsWith("zsh")) {
493
- rcFile = path3.join(home, ".zshrc");
1102
+ rcFile = path5.join(home, ".zshrc");
494
1103
  } else {
495
- rcFile = path3.join(home, ".bashrc");
1104
+ rcFile = path5.join(home, ".bashrc");
496
1105
  }
497
- const existing = fs2.existsSync(rcFile) ? fs2.readFileSync(rcFile, "utf-8") : "";
1106
+ const existing = fs4.existsSync(rcFile) ? fs4.readFileSync(rcFile, "utf-8") : "";
498
1107
  if (existing.includes(SHELL_MARKER)) {
499
1108
  console.log(`
500
1109
  Shell integration already installed in ${rcFile}`);
501
1110
  return;
502
1111
  }
503
- fs2.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
1112
+ fs4.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
504
1113
  console.log(`
505
1114
  Shell integration installed in ${rcFile}`);
506
1115
  console.log("Restart your shell or run: source " + rcFile);
507
1116
  }
508
- var SEEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", ".seen-dirs");
1117
+ var SEEN_FILE = path5.join(os4.homedir(), ".config", "tw-bridge", ".seen-dirs");
509
1118
  function loadSeenDirs() {
510
1119
  try {
511
- const raw = fs2.readFileSync(SEEN_FILE, "utf-8");
1120
+ const raw = fs4.readFileSync(SEEN_FILE, "utf-8");
512
1121
  return new Set(raw.split("\n").filter(Boolean));
513
1122
  } catch {
514
1123
  return /* @__PURE__ */ new Set();
@@ -518,15 +1127,15 @@ function markDirSeen(dir) {
518
1127
  const seen = loadSeenDirs();
519
1128
  if (seen.has(dir)) return;
520
1129
  seen.add(dir);
521
- fs2.mkdirSync(path3.dirname(SEEN_FILE), { recursive: true });
522
- fs2.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
1130
+ fs4.mkdirSync(path5.dirname(SEEN_FILE), { recursive: true });
1131
+ fs4.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
523
1132
  }
524
1133
  function isGitRepo(dir) {
525
1134
  try {
526
1135
  let current = dir;
527
- while (current !== path3.dirname(current)) {
528
- if (fs2.existsSync(path3.join(current, ".git"))) return true;
529
- current = path3.dirname(current);
1136
+ while (current !== path5.dirname(current)) {
1137
+ if (fs4.existsSync(path5.join(current, ".git"))) return true;
1138
+ current = path5.dirname(current);
530
1139
  }
531
1140
  return false;
532
1141
  } catch {
@@ -539,8 +1148,8 @@ async function which() {
539
1148
  for (const [_name, backend] of Object.entries(config.backends)) {
540
1149
  const backendCwd = backend.config?.cwd;
541
1150
  if (!backendCwd) continue;
542
- const resolved = path3.resolve(backendCwd);
543
- if (cwd === resolved || cwd.startsWith(resolved + path3.sep)) {
1151
+ const resolved = path5.resolve(backendCwd);
1152
+ if (cwd === resolved || cwd.startsWith(resolved + path5.sep)) {
544
1153
  const contextTag = backend.match.tags?.[0];
545
1154
  if (contextTag) {
546
1155
  process.stdout.write(contextTag);
@@ -556,15 +1165,15 @@ async function which() {
556
1165
  const seen = loadSeenDirs();
557
1166
  let gitRoot = cwd;
558
1167
  let current = cwd;
559
- while (current !== path3.dirname(current)) {
560
- if (fs2.existsSync(path3.join(current, ".git"))) {
1168
+ while (current !== path5.dirname(current)) {
1169
+ if (fs4.existsSync(path5.join(current, ".git"))) {
561
1170
  gitRoot = current;
562
1171
  break;
563
1172
  }
564
- current = path3.dirname(current);
1173
+ current = path5.dirname(current);
565
1174
  }
566
1175
  if (!seen.has(gitRoot)) {
567
- const dirName = path3.basename(gitRoot);
1176
+ const dirName = path5.basename(gitRoot);
568
1177
  process.stderr.write(
569
1178
  `tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
570
1179
  `
@@ -628,6 +1237,14 @@ async function syncBackend(name, backend, config) {
628
1237
  }
629
1238
  } else if (isDone) {
630
1239
  if (completeTask(existingTask, matchTags)) {
1240
+ if (adapter.onDone) {
1241
+ try {
1242
+ await adapter.onDone(task);
1243
+ } catch (err) {
1244
+ const msg = err instanceof Error ? err.message : String(err);
1245
+ console.error(` \u26A0 [#${backendId}] failed to push completion: ${msg}`);
1246
+ }
1247
+ }
631
1248
  console.log(` \u2713 [#${backendId}] ${task.description}`);
632
1249
  completed++;
633
1250
  }