taskin 4.1.0 → 4.1.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/dist/index.js CHANGED
@@ -28,436 +28,868 @@ var init_esm_shims = __esm({
28
28
  }
29
29
  });
30
30
 
31
- // ../utils/src/security.ts
32
- import { z } from "zod";
33
- function isValidHost(host) {
34
- return HostSchema.safeParse(host).success;
35
- }
36
- function isValidPort(port) {
37
- return PortSchema.safeParse(port).success;
38
- }
39
- function escapeHtml(text) {
40
- if (!text || typeof text !== "string") {
31
+ // ../git-utils/src/commit-message.ts
32
+ function isRecognizedCiSkipTag(tag) {
33
+ const normalized = tag.trim().toLowerCase();
34
+ return CI_SKIP_TAGS.some((known) => known === normalized);
35
+ }
36
+ function appendCiSkipTag(subject, ciSkipTag = DEFAULT_CI_SKIP_TAG) {
37
+ const tag = ciSkipTag.trim();
38
+ if (tag.length === 0) return subject;
39
+ if (subject.endsWith(tag)) return subject;
40
+ return `${subject} ${tag}`;
41
+ }
42
+ function buildTaskStatusCommitMessage(options) {
43
+ const subject = `docs(TASKS): task-${options.taskId} - atualiza status para ${options.status}`;
44
+ return appendCiSkipTag(subject, options.ciSkipTag);
45
+ }
46
+ var CI_SKIP_TAGS, DEFAULT_CI_SKIP_TAG;
47
+ var init_commit_message = __esm({
48
+ "../git-utils/src/commit-message.ts"() {
49
+ "use strict";
50
+ init_esm_shims();
51
+ CI_SKIP_TAGS = ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"];
52
+ DEFAULT_CI_SKIP_TAG = "[skip ci]";
53
+ }
54
+ });
55
+
56
+ // ../git-utils/src/git.ts
57
+ import { execSync } from "child_process";
58
+ function executeGit(command) {
59
+ try {
60
+ return execSync(`git ${command}`, {
61
+ encoding: "utf8",
62
+ stdio: ["pipe", "pipe", "pipe"]
63
+ }).trim();
64
+ } catch {
41
65
  return "";
42
66
  }
43
- const htmlEscapeMap = {
44
- "&": "&",
45
- "<": "&lt;",
46
- ">": "&gt;",
47
- '"': "&quot;",
48
- "'": "&#x27;",
49
- "/": "&#x2F;"
50
- };
51
- return text.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char]);
52
67
  }
53
- var HostSchema, PortSchema, WebSocketUrlSchema, TaskIdSchema2, UserIdSchema, EmailSchema, SafePathSchema, DashboardOptionsSchema;
54
- var init_security = __esm({
55
- "../utils/src/security.ts"() {
68
+ function isGitRepository() {
69
+ return !!executeGit("rev-parse --is-inside-work-tree");
70
+ }
71
+ function getCurrentBranch() {
72
+ return executeGit("branch --show-current");
73
+ }
74
+ function createBranch(branchName, baseBranch) {
75
+ const base = baseBranch || getCurrentBranch();
76
+ executeGit(`checkout -b ${branchName} ${base}`);
77
+ }
78
+ var init_git = __esm({
79
+ "../git-utils/src/git.ts"() {
56
80
  "use strict";
57
81
  init_esm_shims();
58
- HostSchema = z.string().refine(
59
- (host) => {
60
- if (!host || host.length === 0) return false;
61
- if (host === "localhost") return true;
62
- const parts = host.split(".");
63
- const allNumeric = parts.every((p) => /^\d+$/.test(p));
64
- if (allNumeric) {
65
- if (parts.length !== 4) return false;
66
- return parts.every((part) => {
67
- const num = parseInt(part, 10);
68
- return !Number.isNaN(num) && num >= 0 && num <= 255 && part === num.toString();
69
- });
70
- }
71
- const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
72
- return hostnameRegex.test(host);
73
- },
74
- {
75
- message: "Invalid host. Must be localhost, a valid IPv4 address, or hostname."
76
- }
77
- );
78
- PortSchema = z.union([
79
- z.number().int().min(1).max(65535),
80
- z.string().regex(/^\d+$/).transform((val) => parseInt(val, 10)).pipe(z.number().int().min(1).max(65535))
81
- ]);
82
- WebSocketUrlSchema = z.string().refine(
83
- (url) => {
84
- try {
85
- const parsed = new URL(url);
86
- return parsed.protocol === "ws:" || parsed.protocol === "wss:";
87
- } catch {
88
- return false;
89
- }
90
- },
91
- { message: "Invalid WebSocket URL. Must use ws:// or wss:// protocol." }
92
- );
93
- TaskIdSchema2 = z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/, {
94
- message: "Task ID must contain only alphanumeric characters, hyphens, and underscores."
95
- });
96
- UserIdSchema = z.string().min(1).max(100).regex(/^[a-zA-Z0-9._-]+$/, {
97
- message: "User ID must contain only alphanumeric characters, dots, hyphens, and underscores."
98
- });
99
- EmailSchema = z.string().email().max(254);
100
- SafePathSchema = z.string().refine(
101
- (filePath) => {
102
- if (!filePath || filePath.length === 0) return false;
103
- const dangerousPatterns = [
104
- /\.\./,
105
- // Parent directory (..)
106
- /~\//,
107
- // Home directory
108
- /^\//,
109
- // Absolute path
110
- /^[A-Za-z]:\\/
111
- // Windows absolute path
112
- ];
113
- return !dangerousPatterns.some((pattern) => pattern.test(filePath));
114
- },
115
- {
116
- message: "Invalid path. Must be a relative path without traversal patterns."
117
- }
118
- );
119
- DashboardOptionsSchema = z.object({
120
- host: HostSchema.optional(),
121
- port: PortSchema.optional(),
122
- wsPort: PortSchema.optional()
123
- });
124
82
  }
125
83
  });
126
84
 
127
- // ../utils/src/string.ts
128
- function slugify(text) {
129
- return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
130
- }
131
- var init_string = __esm({
132
- "../utils/src/string.ts"() {
85
+ // ../git-utils/src/git.types.ts
86
+ var init_git_types = __esm({
87
+ "../git-utils/src/git.types.ts"() {
133
88
  "use strict";
134
89
  init_esm_shims();
135
90
  }
136
91
  });
137
92
 
138
- // ../utils/src/ui.ts
139
- import chalk3 from "chalk";
140
- var colors2;
141
- var init_ui = __esm({
142
- "../utils/src/ui.ts"() {
93
+ // ../git-utils/src/git-analyzer.ts
94
+ import { exec } from "child_process";
95
+ import { promisify } from "util";
96
+ function isNodeError(error2) {
97
+ return typeof error2 === "object" && error2 !== null && "code" in error2;
98
+ }
99
+ async function executeGit2(command, cwd) {
100
+ try {
101
+ const { stdout } = await execAsync(`git ${command}`, {
102
+ cwd: cwd || process.cwd(),
103
+ encoding: "utf8",
104
+ maxBuffer: 10 * 1024 * 1024,
105
+ // 10MB buffer for large repos
106
+ timeout: 3e4
107
+ // 30 second timeout
108
+ });
109
+ return stdout.trim();
110
+ } catch (error2) {
111
+ if (isNodeError(error2)) {
112
+ if (error2.code === "ENOENT") {
113
+ throw new Error("Git is not installed or not in PATH");
114
+ }
115
+ }
116
+ return "";
117
+ }
118
+ }
119
+ var execAsync, GitAnalyzer;
120
+ var init_git_analyzer = __esm({
121
+ "../git-utils/src/git-analyzer.ts"() {
143
122
  "use strict";
144
123
  init_esm_shims();
145
- colors2 = {
146
- primary: chalk3.blue,
147
- secondary: chalk3.gray,
148
- success: chalk3.green,
149
- warning: chalk3.yellow,
150
- error: chalk3.red,
151
- info: chalk3.cyan,
152
- highlight: chalk3.magenta,
153
- normal: chalk3.white
124
+ execAsync = promisify(exec);
125
+ GitAnalyzer = class {
126
+ constructor(repositoryPath) {
127
+ this.repositoryPath = repositoryPath;
128
+ }
129
+ async isValidRepository() {
130
+ try {
131
+ const result = await executeGit2("rev-parse --is-inside-work-tree", this.repositoryPath);
132
+ return result === "true";
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+ async getRepositoryRoot() {
138
+ const result = await executeGit2("rev-parse --show-toplevel", this.repositoryPath);
139
+ if (!result) {
140
+ throw new Error("Not a git repository");
141
+ }
142
+ return result;
143
+ }
144
+ async getCommits(options = {}) {
145
+ const args = ["log"];
146
+ args.push(
147
+ "--pretty='format:%H|%an|%aI|%s%x00%b'",
148
+ "--numstat"
149
+ // Get file stats
150
+ );
151
+ if (options.since) {
152
+ args.push(`--since="${options.since}"`);
153
+ }
154
+ if (options.until) {
155
+ args.push(`--until=${options.until}`);
156
+ }
157
+ if (options.author) {
158
+ args.push(`--author='${options.author}'`);
159
+ }
160
+ if (options.maxCount) {
161
+ args.push(`-n ${options.maxCount}`);
162
+ }
163
+ if (!options.includeMerges) {
164
+ args.push("--no-merges");
165
+ }
166
+ if (options.filePath) {
167
+ args.push("--", options.filePath);
168
+ }
169
+ const command = args.join(" ");
170
+ const output = await executeGit2(command, this.repositoryPath);
171
+ if (!output) {
172
+ return [];
173
+ }
174
+ return this.parseCommits(output);
175
+ }
176
+ parseCommits(output) {
177
+ const commits = [];
178
+ const lines = output.split("\n");
179
+ let i = 0;
180
+ while (i < lines.length) {
181
+ const line = lines[i];
182
+ if (line === void 0) break;
183
+ if (!line.trim()) {
184
+ i++;
185
+ continue;
186
+ }
187
+ if (line.includes("|")) {
188
+ const [hash, author, date, messageWithBody] = line.split("|");
189
+ if (hash === void 0 || author === void 0 || date === void 0 || messageWithBody === void 0) {
190
+ i++;
191
+ continue;
192
+ }
193
+ if (!/^[0-9a-f]{6,40}$/i.test(hash)) {
194
+ i++;
195
+ continue;
196
+ }
197
+ const nullByteIndex = messageWithBody.indexOf("\0");
198
+ const subject = nullByteIndex !== -1 ? messageWithBody.substring(0, nullByteIndex) : messageWithBody;
199
+ const bodyLines = [];
200
+ if (nullByteIndex !== -1) {
201
+ bodyLines.push(messageWithBody.substring(nullByteIndex + 1));
202
+ }
203
+ i++;
204
+ while (i < lines.length) {
205
+ const bodyLine = lines[i];
206
+ if (bodyLine === void 0 || bodyLine.includes("|") || bodyLine.includes(" ")) break;
207
+ if (bodyLine.trim()) {
208
+ bodyLines.push(bodyLine);
209
+ }
210
+ i++;
211
+ }
212
+ const body = bodyLines.join("\n");
213
+ const fullMessage = body ? `${subject}
214
+
215
+ ${body}` : subject;
216
+ let filesChanged = 0;
217
+ let linesAdded = 0;
218
+ let linesRemoved = 0;
219
+ while (i < lines.length) {
220
+ const rawStatLine = lines[i];
221
+ if (rawStatLine === void 0 || rawStatLine.includes("|")) break;
222
+ const statLine = rawStatLine.trim();
223
+ if (!statLine) {
224
+ i++;
225
+ continue;
226
+ }
227
+ const [added, removed] = statLine.split(" ");
228
+ if (added !== "-" && removed !== "-") {
229
+ linesAdded += parseInt(added || "0", 10);
230
+ linesRemoved += parseInt(removed || "0", 10);
231
+ filesChanged++;
232
+ }
233
+ i++;
234
+ }
235
+ commits.push({
236
+ hash,
237
+ author,
238
+ date,
239
+ message: subject,
240
+ filesChanged,
241
+ linesAdded,
242
+ linesRemoved,
243
+ coAuthors: this.extractCoAuthors(fullMessage)
244
+ });
245
+ } else {
246
+ i++;
247
+ }
248
+ }
249
+ return commits;
250
+ }
251
+ extractCoAuthors(message) {
252
+ const coAuthorRegex = /Co-authored-by:\s*(.+?)\s*<(.+?)>/gi;
253
+ const matches = Array.from(message.matchAll(coAuthorRegex));
254
+ const names = matches.flatMap((match) => match[1]?.trim() ?? []);
255
+ return names.length > 0 ? names : void 0;
256
+ }
257
+ async getDiff(from = "HEAD", to = "") {
258
+ const args = ["diff", "--numstat"];
259
+ if (to) {
260
+ args.push(`${from}..${to}`);
261
+ } else {
262
+ args.push(from);
263
+ }
264
+ const output = await executeGit2(args.join(" "), this.repositoryPath);
265
+ return this.parseDiff(output);
266
+ }
267
+ parseDiff(output) {
268
+ const files = [];
269
+ let totalLinesAdded = 0;
270
+ let totalLinesRemoved = 0;
271
+ if (!output) {
272
+ return {
273
+ files,
274
+ totalLinesAdded,
275
+ totalLinesRemoved,
276
+ netChange: 0
277
+ };
278
+ }
279
+ const lines = output.split("\n").filter((l) => l.trim());
280
+ for (const line of lines) {
281
+ const [added, removed, path12] = line.split(" ");
282
+ if (added === void 0 || removed === void 0 || path12 === void 0) continue;
283
+ if (added === "-" || removed === "-") {
284
+ continue;
285
+ }
286
+ const linesAdded = parseInt(added, 10);
287
+ const linesRemoved = parseInt(removed, 10);
288
+ totalLinesAdded += linesAdded;
289
+ totalLinesRemoved += linesRemoved;
290
+ files.push({
291
+ path: path12,
292
+ linesAdded,
293
+ linesRemoved,
294
+ changeType: "modified"
295
+ // Simplified - could be enhanced with --name-status
296
+ });
297
+ }
298
+ return {
299
+ files,
300
+ totalLinesAdded,
301
+ totalLinesRemoved,
302
+ netChange: totalLinesAdded - totalLinesRemoved
303
+ };
304
+ }
305
+ async getFileDiff(filePath, from = "HEAD", to = "") {
306
+ const args = ["diff", "--numstat"];
307
+ if (to) {
308
+ args.push(`${from}..${to}`);
309
+ } else {
310
+ args.push(from);
311
+ }
312
+ args.push("--", filePath);
313
+ const output = await executeGit2(args.join(" "), this.repositoryPath);
314
+ if (!output) {
315
+ return null;
316
+ }
317
+ const [added, removed, path12] = output.split(" ");
318
+ if (added === void 0 || removed === void 0 || path12 === void 0) {
319
+ return null;
320
+ }
321
+ if (added === "-" || removed === "-") {
322
+ return null;
323
+ }
324
+ return {
325
+ path: path12,
326
+ linesAdded: parseInt(added, 10),
327
+ linesRemoved: parseInt(removed, 10),
328
+ changeType: "modified"
329
+ };
330
+ }
331
+ async getBlame(filePath) {
332
+ const args = ["blame", "--line-porcelain", filePath];
333
+ const output = await executeGit2(args.join(" "), this.repositoryPath);
334
+ if (!output) {
335
+ return [];
336
+ }
337
+ return this.parseBlame(output);
338
+ }
339
+ parseBlame(output) {
340
+ const lines = output.split("\n");
341
+ const blameInfo = [];
342
+ let currentHash = "";
343
+ let currentAuthor = "";
344
+ let currentDate = "";
345
+ let lineNumber = 0;
346
+ for (const line of lines) {
347
+ if (line.match(/^[0-9a-f]{6,40}/i)) {
348
+ const [hash, , finalLine] = line.split(" ");
349
+ currentHash = hash ?? "";
350
+ lineNumber = parseInt(finalLine ?? "", 10);
351
+ } else if (line.startsWith("author ")) {
352
+ currentAuthor = line.substring(7);
353
+ } else if (line.startsWith("author-time ")) {
354
+ const timestamp = parseInt(line.substring(12), 10);
355
+ currentDate = new Date(timestamp * 1e3).toISOString();
356
+ } else if (line.startsWith(" ")) {
357
+ const content = line.substring(1);
358
+ blameInfo.push({
359
+ lineNumber,
360
+ commitHash: currentHash,
361
+ author: currentAuthor,
362
+ date: new Date(currentDate),
363
+ content
364
+ });
365
+ }
366
+ }
367
+ return blameInfo;
368
+ }
369
+ async getAuthors(options = {}) {
370
+ const args = ["shortlog", "-sne"];
371
+ if (options.since) {
372
+ args.push(`--since=${options.since}`);
373
+ }
374
+ if (options.until) {
375
+ args.push(`--until=${options.until}`);
376
+ }
377
+ if (!options.includeMerges) {
378
+ args.push("--no-merges");
379
+ }
380
+ if (options.filePath) {
381
+ args.push("--", options.filePath);
382
+ }
383
+ const command = args.join(" ");
384
+ const output = await executeGit2(command, this.repositoryPath);
385
+ if (!output) {
386
+ return [];
387
+ }
388
+ return this.parseAuthors(output);
389
+ }
390
+ parseAuthors(output) {
391
+ const lines = output.split("\n").filter((l) => l.trim());
392
+ const authors = [];
393
+ for (const line of lines) {
394
+ const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+?)>/);
395
+ if (match) {
396
+ const [, commits, name, email] = match;
397
+ if (commits === void 0 || name === void 0 || email === void 0) continue;
398
+ authors.push({
399
+ name,
400
+ email,
401
+ commits: parseInt(commits, 10)
402
+ });
403
+ }
404
+ }
405
+ return authors;
406
+ }
407
+ async getFileHistory(filePath, options = {}) {
408
+ return this.getCommits({
409
+ ...options,
410
+ filePath
411
+ });
412
+ }
154
413
  };
155
414
  }
156
415
  });
157
416
 
158
- // ../utils/src/index.ts
159
- var init_src = __esm({
160
- "../utils/src/index.ts"() {
417
+ // ../git-utils/src/git-analyzer.types.ts
418
+ var init_git_analyzer_types = __esm({
419
+ "../git-utils/src/git-analyzer.types.ts"() {
161
420
  "use strict";
162
421
  init_esm_shims();
163
- init_security();
164
- init_string();
165
- init_ui();
166
422
  }
167
423
  });
168
424
 
169
- // ../file-system-task-provider/src/assignee-identity.ts
170
- function classifyAssignee(raw, registry) {
171
- const trimmed = raw.trim();
172
- if (trimmed === "" || PLACEHOLDER_ASSIGNEES.includes(trimmed.toLowerCase())) {
173
- return { kind: "unassigned", raw };
174
- }
175
- const user = registry.resolveUser(trimmed);
176
- if (user) {
177
- return { kind: "resolved", raw, user };
178
- }
179
- const folded = fold(trimmed);
180
- const matches = registry.getAllUsers().filter((candidate) => fold(candidate.id) === folded || fold(candidate.name) === folded);
181
- const [onlyMatch] = matches;
182
- if (onlyMatch && matches.length === 1) {
183
- return { kind: "correctable", raw, user: onlyMatch };
184
- }
185
- return { kind: "unknown", raw };
186
- }
187
- function validateAssignees(tasks, registry) {
188
- const issues = [];
189
- for (const task of tasks) {
190
- if (task.assignee === void 0) continue;
191
- const identity = classifyAssignee(task.assignee, registry);
192
- switch (identity.kind) {
193
- case "resolved":
194
- case "unassigned":
195
- break;
196
- case "correctable":
197
- issues.push({
198
- file: task.file,
199
- message: `Assignee "${identity.raw.trim()}" is not in the user registry, but folds onto exactly one registered user.`,
200
- severity: "warning",
201
- suggestion: `Rewrite it as "${identity.user.id}" \u2014 lint --fix does this.`
202
- });
203
- break;
204
- case "unknown":
205
- issues.push({
206
- file: task.file,
207
- message: `Assignee "${identity.raw.trim()}" resolves to nobody in the user registry, so it silently becomes a fabricated temporary user.`,
208
- severity: "warning",
209
- suggestion: `Register them in ${".taskin/.taskin-users.json"}, or fix the spelling \u2014 too ambiguous for --fix to decide.`
210
- });
211
- break;
212
- default:
213
- identity;
214
- }
215
- }
216
- return issues;
217
- }
218
- async function fixAssignees(tasks, registry, io) {
219
- const rewritten = [];
220
- for (const task of tasks) {
221
- if (task.assignee === void 0) continue;
222
- const identity = classifyAssignee(task.assignee, registry);
223
- if (identity.kind !== "correctable") continue;
224
- const content = await io.readFile(task.file);
225
- const next = content.replace(/^(Assignee:[ \t]*)(.*)$/im, (_line, label) => `${label}${identity.user.id}`);
226
- if (next !== content) {
227
- await io.writeFile(task.file, next);
228
- rewritten.push(task.file);
229
- }
425
+ // ../git-utils/src/git-service.ts
426
+ import { execSync as execSync2 } from "child_process";
427
+ var GitService;
428
+ var init_git_service = __esm({
429
+ "../git-utils/src/git-service.ts"() {
430
+ "use strict";
431
+ init_esm_shims();
432
+ init_commit_message();
433
+ init_git();
434
+ GitService = class {
435
+ constructor(cwd = process.cwd(), options = {}) {
436
+ this.cwd = cwd;
437
+ this.ciSkipTag = options.ciSkipTag ?? DEFAULT_CI_SKIP_TAG;
438
+ }
439
+ ciSkipTag;
440
+ async addFiles(pattern) {
441
+ try {
442
+ execSync2(`git add ${pattern}`, {
443
+ cwd: this.cwd,
444
+ stdio: "ignore"
445
+ });
446
+ return true;
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+ async commit(message) {
452
+ try {
453
+ execSync2(`git commit -m "${message}"`, {
454
+ cwd: this.cwd,
455
+ stdio: "ignore"
456
+ });
457
+ return true;
458
+ } catch {
459
+ return false;
460
+ }
461
+ }
462
+ async addAndCommit(pattern, message) {
463
+ const added = await this.addFiles(pattern);
464
+ if (!added) return false;
465
+ return this.commit(message);
466
+ }
467
+ async commitTaskStatusChange(taskId, status) {
468
+ const pattern = `TASKS/task-${taskId}-*.md`;
469
+ const message = buildTaskStatusCommitMessage({ taskId, status, ciSkipTag: this.ciSkipTag });
470
+ return this.addAndCommit(pattern, message);
471
+ }
472
+ async commitTaskStatusChangeOnBranch(taskId, status, defaultBranch) {
473
+ if (!defaultBranch) {
474
+ return this.commitTaskStatusChange(taskId, status);
475
+ }
476
+ try {
477
+ const currentBranch = execSync2("git rev-parse --abbrev-ref HEAD", {
478
+ cwd: this.cwd,
479
+ encoding: "utf8",
480
+ stdio: "pipe"
481
+ }).trim();
482
+ if (currentBranch === defaultBranch) {
483
+ return this.commitTaskStatusChange(taskId, status);
484
+ }
485
+ const taskPattern = `TASKS/task-${taskId}-*.md`;
486
+ let taskFileContent = null;
487
+ let taskFilePath = null;
488
+ try {
489
+ const files = execSync2(`git ls-files -m ${taskPattern}`, {
490
+ cwd: this.cwd,
491
+ encoding: "utf8",
492
+ stdio: "pipe"
493
+ }).trim();
494
+ if (files) {
495
+ taskFilePath = files.split("\n")[0] ?? null;
496
+ const { readFileSync: readFileSync4 } = await import("fs");
497
+ taskFileContent = readFileSync4(`${this.cwd}/${taskFilePath}`, "utf-8");
498
+ }
499
+ } catch {
500
+ }
501
+ const hasChanges = await this.hasUncommittedChanges();
502
+ let stashed = false;
503
+ try {
504
+ if (hasChanges) {
505
+ execSync2('git stash push -u -m "taskin-temp-stash"', {
506
+ cwd: this.cwd,
507
+ stdio: "ignore"
508
+ });
509
+ stashed = true;
510
+ }
511
+ execSync2(`git checkout ${defaultBranch}`, {
512
+ cwd: this.cwd,
513
+ stdio: "ignore"
514
+ });
515
+ let committed = false;
516
+ if (taskFileContent && taskFilePath) {
517
+ const { writeFileSync: writeFileSync3 } = await import("fs");
518
+ writeFileSync3(`${this.cwd}/${taskFilePath}`, taskFileContent, "utf-8");
519
+ const message = buildTaskStatusCommitMessage({ taskId, status, ciSkipTag: this.ciSkipTag });
520
+ committed = await this.addAndCommit(taskPattern, message);
521
+ } else {
522
+ const message = buildTaskStatusCommitMessage({ taskId, status, ciSkipTag: this.ciSkipTag });
523
+ committed = await this.addAndCommit(taskPattern, message);
524
+ }
525
+ execSync2(`git checkout ${currentBranch}`, {
526
+ cwd: this.cwd,
527
+ stdio: "ignore"
528
+ });
529
+ if (stashed) {
530
+ execSync2("git stash pop", {
531
+ cwd: this.cwd,
532
+ stdio: "ignore"
533
+ });
534
+ }
535
+ return committed;
536
+ } catch {
537
+ try {
538
+ execSync2(`git checkout ${currentBranch}`, {
539
+ cwd: this.cwd,
540
+ stdio: "ignore"
541
+ });
542
+ if (stashed) {
543
+ execSync2("git stash pop", {
544
+ cwd: this.cwd,
545
+ stdio: "ignore"
546
+ });
547
+ }
548
+ } catch {
549
+ }
550
+ return false;
551
+ }
552
+ } catch {
553
+ return false;
554
+ }
555
+ }
556
+ async hasUncommittedChanges() {
557
+ try {
558
+ const output = execSync2("git status --porcelain", {
559
+ cwd: this.cwd,
560
+ encoding: "utf8",
561
+ stdio: "pipe"
562
+ });
563
+ return output.trim().length > 0;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+ async getCurrentBranch() {
569
+ try {
570
+ return execSync2("git branch --show-current", {
571
+ cwd: this.cwd,
572
+ encoding: "utf8",
573
+ stdio: "pipe"
574
+ }).trim();
575
+ } catch {
576
+ return "";
577
+ }
578
+ }
579
+ async isGitRepository() {
580
+ return Promise.resolve(isGitRepository());
581
+ }
582
+ async createBranch(branchName, baseBranch) {
583
+ try {
584
+ createBranch(branchName, baseBranch);
585
+ return true;
586
+ } catch {
587
+ return false;
588
+ }
589
+ }
590
+ async checkoutBranch(branchName) {
591
+ try {
592
+ execSync2(`git checkout ${branchName}`, {
593
+ cwd: this.cwd,
594
+ stdio: "ignore"
595
+ });
596
+ return true;
597
+ } catch {
598
+ return false;
599
+ }
600
+ }
601
+ async fetch(remote = "origin") {
602
+ try {
603
+ execSync2(`git fetch ${remote}`, {
604
+ cwd: this.cwd,
605
+ stdio: "ignore"
606
+ });
607
+ return true;
608
+ } catch {
609
+ return false;
610
+ }
611
+ }
612
+ async rebase(branch) {
613
+ try {
614
+ execSync2(`git rebase ${branch}`, {
615
+ cwd: this.cwd,
616
+ stdio: "ignore"
617
+ });
618
+ return true;
619
+ } catch {
620
+ return false;
621
+ }
622
+ }
623
+ async push(branch, remote = "origin") {
624
+ try {
625
+ execSync2(`git push ${remote} ${branch}`, {
626
+ cwd: this.cwd,
627
+ stdio: "ignore"
628
+ });
629
+ return true;
630
+ } catch {
631
+ return false;
632
+ }
633
+ }
634
+ async abortRebase() {
635
+ try {
636
+ execSync2("git rebase --abort", {
637
+ cwd: this.cwd,
638
+ stdio: "ignore"
639
+ });
640
+ return true;
641
+ } catch {
642
+ return false;
643
+ }
644
+ }
645
+ async checkoutFile(branch, pattern) {
646
+ try {
647
+ execSync2(`git checkout ${branch} -- ${pattern}`, {
648
+ cwd: this.cwd,
649
+ stdio: "ignore"
650
+ });
651
+ return true;
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+ };
230
657
  }
231
- return rewritten;
232
- }
233
- function isSeededUser(user) {
234
- const capitalized = user.id.charAt(0).toUpperCase() + user.id.slice(1);
235
- return user.email === `${user.id}@example.com` && user.name === capitalized;
236
- }
237
- function validateSeededUsers(assignees, registry) {
238
- const referenced = new Set(
239
- assignees.flatMap((raw) => {
240
- if (raw === void 0) return [];
241
- const identity = classifyAssignee(raw, registry);
242
- return identity.kind === "resolved" || identity.kind === "correctable" ? [identity.user.id] : [];
243
- })
244
- );
245
- return registry.getAllUsers().filter((user) => isSeededUser(user) && !referenced.has(user.id)).map((user) => ({
246
- file: `${".taskin/.taskin-users.json"} (${user.id})`,
247
- message: `User "${user.id}" looks like the placeholder that older taskin versions seeded on init (${user.email}), and no task points at it.`,
248
- severity: "warning",
249
- suggestion: `Remove it from the registry, or give it the real name and e-mail of whoever it stands for.`
250
- }));
251
- }
252
- var PLACEHOLDER_ASSIGNEES, foldAssignee, fold;
253
- var init_assignee_identity = __esm({
254
- "../file-system-task-provider/src/assignee-identity.ts"() {
658
+ });
659
+
660
+ // ../git-utils/src/git-service.types.ts
661
+ var init_git_service_types = __esm({
662
+ "../git-utils/src/git-service.types.ts"() {
255
663
  "use strict";
256
664
  init_esm_shims();
257
- PLACEHOLDER_ASSIGNEES = [
258
- "a definir",
259
- "to be defined",
260
- "nome do responsavel",
261
- "nome do respons\xE1vel",
262
- "tbd",
263
- "-"
264
- ];
265
- foldAssignee = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
266
- fold = foldAssignee;
267
665
  }
268
666
  });
269
667
 
270
- // ../file-system-task-provider/src/auto-sync.ts
271
- function isNonFastForwardError(error2) {
272
- if (error2 instanceof Error) {
273
- return error2.message.toLowerCase().includes("non-fast-forward");
668
+ // ../git-utils/src/index.ts
669
+ var init_src = __esm({
670
+ "../git-utils/src/index.ts"() {
671
+ "use strict";
672
+ init_esm_shims();
673
+ init_commit_message();
674
+ init_git();
675
+ init_git_types();
676
+ init_git_analyzer();
677
+ init_git_analyzer_types();
678
+ init_git_service();
679
+ init_git_service_types();
274
680
  }
275
- return false;
681
+ });
682
+
683
+ // ../utils/src/security.ts
684
+ import { z } from "zod";
685
+ function isValidHost(host) {
686
+ return HostSchema.safeParse(host).success;
276
687
  }
277
- async function syncBeforeCreate(git, config) {
278
- if (!config.autoSync || !config.defaultBranch) {
279
- if (config.autoSync && !config.defaultBranch) {
280
- console.warn("autoSync is enabled but no defaultBranch is configured. Nothing will be synced.");
281
- }
282
- return;
283
- }
284
- const fetchOk = await safeCall(() => git.fetch());
285
- if (!fetchOk) {
286
- throw new Error("Fetch failed. Check your network connection.");
287
- }
288
- const rebaseOk = await safeCall(() => git.rebase(`origin/${config.defaultBranch}`));
289
- if (!rebaseOk) {
290
- await git.abortRebase();
291
- throw new Error("Rebase failed due to conflict. Aborted.");
292
- }
688
+ function isValidPort(port) {
689
+ return PortSchema.safeParse(port).success;
293
690
  }
294
- async function safeCall(fn) {
295
- try {
296
- return await fn();
297
- } catch {
298
- return false;
299
- }
691
+ function escapeHtml(text) {
692
+ if (!text || typeof text !== "string") {
693
+ return "";
694
+ }
695
+ const htmlEscapeMap = {
696
+ "&": "&amp;",
697
+ "<": "&lt;",
698
+ ">": "&gt;",
699
+ '"': "&quot;",
700
+ "'": "&#x27;",
701
+ "/": "&#x2F;"
702
+ };
703
+ return text.replace(/[&<>"'/]/g, (char) => htmlEscapeMap[char]);
300
704
  }
301
- async function attemptPushWithRetry(git, pattern, message, branch, maxAttempts) {
302
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
303
- if (attempt > 1) {
304
- const fetchOk = await safeCall(() => git.fetch());
305
- if (!fetchOk) {
306
- throw new Error("Fetch failed during retry.");
307
- }
308
- const rebaseOk = await safeCall(() => git.rebase(`origin/${branch}`));
309
- if (!rebaseOk) {
310
- throw new Error("Rebase failed during retry.");
311
- }
312
- }
313
- await git.addAndCommit(pattern, message);
314
- try {
315
- const pushOk = await git.push(branch);
316
- if (pushOk) {
317
- return;
318
- }
319
- } catch (error2) {
320
- if (!isNonFastForwardError(error2)) {
321
- throw error2;
705
+ var HostSchema, PortSchema, WebSocketUrlSchema, TaskIdSchema2, UserIdSchema, EmailSchema, SafePathSchema, DashboardOptionsSchema;
706
+ var init_security = __esm({
707
+ "../utils/src/security.ts"() {
708
+ "use strict";
709
+ init_esm_shims();
710
+ HostSchema = z.string().refine(
711
+ (host) => {
712
+ if (!host || host.length === 0) return false;
713
+ if (host === "localhost") return true;
714
+ const parts = host.split(".");
715
+ const allNumeric = parts.every((p) => /^\d+$/.test(p));
716
+ if (allNumeric) {
717
+ if (parts.length !== 4) return false;
718
+ return parts.every((part) => {
719
+ const num = parseInt(part, 10);
720
+ return !Number.isNaN(num) && num >= 0 && num <= 255 && part === num.toString();
721
+ });
722
+ }
723
+ const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
724
+ return hostnameRegex.test(host);
725
+ },
726
+ {
727
+ message: "Invalid host. Must be localhost, a valid IPv4 address, or hostname."
322
728
  }
323
- if (attempt >= maxAttempts) {
324
- throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
729
+ );
730
+ PortSchema = z.union([
731
+ z.number().int().min(1).max(65535),
732
+ z.string().regex(/^\d+$/).transform((val) => parseInt(val, 10)).pipe(z.number().int().min(1).max(65535))
733
+ ]);
734
+ WebSocketUrlSchema = z.string().refine(
735
+ (url) => {
736
+ try {
737
+ const parsed = new URL(url);
738
+ return parsed.protocol === "ws:" || parsed.protocol === "wss:";
739
+ } catch {
740
+ return false;
741
+ }
742
+ },
743
+ { message: "Invalid WebSocket URL. Must use ws:// or wss:// protocol." }
744
+ );
745
+ TaskIdSchema2 = z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/, {
746
+ message: "Task ID must contain only alphanumeric characters, hyphens, and underscores."
747
+ });
748
+ UserIdSchema = z.string().min(1).max(100).regex(/^[a-zA-Z0-9._-]+$/, {
749
+ message: "User ID must contain only alphanumeric characters, dots, hyphens, and underscores."
750
+ });
751
+ EmailSchema = z.string().email().max(254);
752
+ SafePathSchema = z.string().refine(
753
+ (filePath) => {
754
+ if (!filePath || filePath.length === 0) return false;
755
+ const dangerousPatterns = [
756
+ /\.\./,
757
+ // Parent directory (..)
758
+ /~\//,
759
+ // Home directory
760
+ /^\//,
761
+ // Absolute path
762
+ /^[A-Za-z]:\\/
763
+ // Windows absolute path
764
+ ];
765
+ return !dangerousPatterns.some((pattern) => pattern.test(filePath));
766
+ },
767
+ {
768
+ message: "Invalid path. Must be a relative path without traversal patterns."
325
769
  }
326
- continue;
327
- }
328
- if (attempt >= maxAttempts) {
329
- throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
330
- }
331
- }
332
- throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
333
- }
334
- async function pushAfterCreate(git, options) {
335
- const pattern = `TASKS/task-${options.taskId}-*.md`;
336
- const message = `docs(TASKS): task-${options.taskId} - ${options.title} [skip-ci]`;
337
- await attemptPushWithRetry(git, pattern, message, options.defaultBranch, MAX_RETRY_ATTEMPTS);
338
- return true;
339
- }
340
- async function getNextTaskNumberAfterSync(options) {
341
- const maxLocal = options.localCount;
342
- const maxRemote = options.remoteCount;
343
- if (options.autoSync) {
344
- const maxNumber = Math.max(maxLocal, maxRemote);
345
- return maxNumber + 1;
346
- }
347
- return maxLocal + 1;
348
- }
349
- async function createTaskWithSync(git, config, taskOptions) {
350
- await syncBeforeCreate(git, config);
351
- const nextNumber = await getNextTaskNumberAfterSync({
352
- autoSync: config.autoSync,
353
- localCount: 0,
354
- remoteCount: 0
355
- });
356
- const taskId = String(nextNumber).padStart(3, "0");
357
- if (config.autoSync && config.defaultBranch) {
358
- await pushAfterCreate(git, {
359
- taskId,
360
- title: taskOptions.title,
361
- defaultBranch: config.defaultBranch
770
+ );
771
+ DashboardOptionsSchema = z.object({
772
+ host: HostSchema.optional(),
773
+ port: PortSchema.optional(),
774
+ wsPort: PortSchema.optional()
362
775
  });
363
776
  }
364
- return { taskId };
777
+ });
778
+
779
+ // ../utils/src/string.ts
780
+ function slugify(text) {
781
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
365
782
  }
366
- async function squashTaskFileOnDone(git, options) {
367
- if (!options.originBranch) {
368
- return false;
369
- }
370
- const currentBranch = await git.getCurrentBranch();
371
- const originBranch = options.originBranch;
372
- const pattern = `TASKS/task-${options.taskId}-*.md`;
373
- const assetsPattern = `TASKS/assets/task-${options.taskId}/`;
374
- const coResult = await git.checkoutBranch(originBranch);
375
- if (!coResult) {
376
- await git.checkoutBranch(currentBranch);
377
- return false;
378
- }
379
- try {
380
- const patternsToAdd = [];
381
- const fileOk = await git.checkoutFile(options.defaultBranch, pattern);
382
- if (fileOk) {
383
- patternsToAdd.push(pattern);
384
- }
385
- const assetsOk = await git.checkoutFile(options.defaultBranch, assetsPattern);
386
- if (assetsOk) {
387
- patternsToAdd.push(assetsPattern);
388
- }
389
- if (patternsToAdd.length === 0) {
390
- await git.checkoutBranch(currentBranch);
391
- return false;
392
- }
393
- const combinedPattern = patternsToAdd.join(" ");
394
- const addOk = await git.addFiles(combinedPattern);
395
- if (!addOk) {
396
- await git.checkoutBranch(currentBranch);
397
- return false;
398
- }
399
- const message = `docs(TASKS): task-${options.taskId} - done [skip-ci]`;
400
- const commitOk = await git.commit(message);
401
- if (!commitOk) {
402
- await git.checkoutBranch(currentBranch);
403
- return false;
404
- }
405
- for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
406
- if (attempt > 1) {
407
- await safeCall(() => git.fetch());
408
- await safeCall(() => git.rebase(`origin/${originBranch}`));
409
- }
410
- const pushOk = await safeCall(() => git.push(originBranch));
411
- if (pushOk === true) {
412
- await git.checkoutBranch(currentBranch);
413
- return true;
414
- }
415
- if (attempt >= MAX_RETRY_ATTEMPTS) {
416
- await git.checkoutBranch(currentBranch);
417
- throw new Error(`Squash push rejected after ${MAX_RETRY_ATTEMPTS} retries.`);
418
- }
419
- }
420
- await git.checkoutBranch(currentBranch);
421
- throw new Error(`Squash push rejected after ${MAX_RETRY_ATTEMPTS} retries.`);
422
- } catch (error2) {
423
- await git.checkoutBranch(currentBranch);
424
- if (error2 instanceof Error) {
425
- throw error2;
426
- }
427
- return false;
783
+ var init_string = __esm({
784
+ "../utils/src/string.ts"() {
785
+ "use strict";
786
+ init_esm_shims();
428
787
  }
429
- }
430
- var MAX_RETRY_ATTEMPTS;
431
- var init_auto_sync = __esm({
432
- "../file-system-task-provider/src/auto-sync.ts"() {
788
+ });
789
+
790
+ // ../utils/src/ui.ts
791
+ import chalk3 from "chalk";
792
+ var colors2;
793
+ var init_ui = __esm({
794
+ "../utils/src/ui.ts"() {
433
795
  "use strict";
434
796
  init_esm_shims();
435
- MAX_RETRY_ATTEMPTS = 3;
797
+ colors2 = {
798
+ primary: chalk3.blue,
799
+ secondary: chalk3.gray,
800
+ success: chalk3.green,
801
+ warning: chalk3.yellow,
802
+ error: chalk3.red,
803
+ info: chalk3.cyan,
804
+ highlight: chalk3.magenta,
805
+ normal: chalk3.white
806
+ };
436
807
  }
437
808
  });
438
809
 
439
- // ../file-system-task-provider/src/inline-metadata.ts
440
- function stripHardBreak(value) {
441
- return value.replace(/\\$/, "").trim();
442
- }
443
- var HARD_BREAK;
444
- var init_inline_metadata = __esm({
445
- "../file-system-task-provider/src/inline-metadata.ts"() {
810
+ // ../utils/src/index.ts
811
+ var init_src2 = __esm({
812
+ "../utils/src/index.ts"() {
446
813
  "use strict";
447
814
  init_esm_shims();
448
- HARD_BREAK = "\\";
815
+ init_security();
816
+ init_string();
817
+ init_ui();
449
818
  }
450
819
  });
451
820
 
452
- // ../file-system-task-provider/src/metadata-style/metadata-block.ts
453
- function headerEndIndex(lines) {
454
- const index = lines.findIndex((line) => SECTION_HEADING.test(line));
455
- return index === -1 ? lines.length : index;
821
+ // ../file-system-task-provider/src/i18n.ts
822
+ function getI18n(locale = "en-US") {
823
+ return i18nConfig[locale];
456
824
  }
457
- function readMetadataBlock(content) {
458
- const lines = splitLines(content);
459
- const headerEnd = headerEndIndex(lines);
460
- let start = -1;
825
+ function detectLocale(content) {
826
+ if (content.includes("## Descri\xE7\xE3o") || content.includes("## Tipo") || content.includes("## Respons\xE1vel") || content.includes("## Tarefas")) {
827
+ return "pt-BR";
828
+ }
829
+ return "en-US";
830
+ }
831
+ var i18nConfig;
832
+ var init_i18n = __esm({
833
+ "../file-system-task-provider/src/i18n.ts"() {
834
+ "use strict";
835
+ init_esm_shims();
836
+ i18nConfig = {
837
+ "en-US": {
838
+ status: "Status",
839
+ type: "Type",
840
+ assignee: "Assignee",
841
+ description: "Description",
842
+ tasks: "Tasks",
843
+ notes: "Notes",
844
+ defaultAssignee: "To be defined",
845
+ descriptionPlaceholder: "Add task description here...",
846
+ notesPlaceholder: "Add any relevant notes or links here.",
847
+ priority: "Priority",
848
+ group: "Group",
849
+ groupName: "GroupName",
850
+ difficulty: "Difficulty"
851
+ },
852
+ "pt-BR": {
853
+ status: "Status",
854
+ type: "Tipo",
855
+ assignee: "Respons\xE1vel",
856
+ description: "Descri\xE7\xE3o",
857
+ tasks: "Tarefas",
858
+ notes: "Notas",
859
+ defaultAssignee: "A definir",
860
+ descriptionPlaceholder: "Adicione a descri\xE7\xE3o da tarefa aqui...",
861
+ notesPlaceholder: "Adicione notas ou links relevantes aqui.",
862
+ priority: "Prioridade",
863
+ group: "Grupo",
864
+ groupName: "NomeGrupo",
865
+ difficulty: "Dificuldade"
866
+ }
867
+ };
868
+ }
869
+ });
870
+
871
+ // ../file-system-task-provider/src/inline-metadata.ts
872
+ function stripHardBreak(value) {
873
+ return value.replace(/\\$/, "").trim();
874
+ }
875
+ var HARD_BREAK;
876
+ var init_inline_metadata = __esm({
877
+ "../file-system-task-provider/src/inline-metadata.ts"() {
878
+ "use strict";
879
+ init_esm_shims();
880
+ HARD_BREAK = "\\";
881
+ }
882
+ });
883
+
884
+ // ../file-system-task-provider/src/metadata-style/metadata-block.ts
885
+ function headerEndIndex(lines) {
886
+ const index = lines.findIndex((line) => SECTION_HEADING.test(line));
887
+ return index === -1 ? lines.length : index;
888
+ }
889
+ function readMetadataBlock(content) {
890
+ const lines = splitLines(content);
891
+ const headerEnd = headerEndIndex(lines);
892
+ let start = -1;
461
893
  for (let index = 0; index < headerEnd; index++) {
462
894
  const line = lines[index] ?? "";
463
895
  if (isHeading(line) || line.trim() === "") continue;
@@ -468,16 +900,24 @@ function readMetadataBlock(content) {
468
900
  }
469
901
  if (start === -1) return void 0;
470
902
  let end = start;
471
- while (end < headerEnd) {
472
- const line = lines[end] ?? "";
473
- if (isHeading(line) || !METADATA_LINE.test(line)) break;
474
- end++;
903
+ let cursor = start;
904
+ while (cursor < headerEnd) {
905
+ const line = lines[cursor] ?? "";
906
+ if (isHeading(line)) break;
907
+ if (line.trim() === "") {
908
+ cursor++;
909
+ continue;
910
+ }
911
+ if (!METADATA_LINE.test(line)) break;
912
+ cursor++;
913
+ end = cursor;
475
914
  }
476
- const blockLines = lines.slice(start, end);
477
915
  const fields = [];
478
- for (const line of blockLines) {
916
+ const blockLines = [];
917
+ for (const line of lines.slice(start, end)) {
479
918
  const match = line.match(METADATA_LINE);
480
919
  if (!match?.[1]) continue;
920
+ blockLines.push(line);
481
921
  fields.push({ label: match[1], value: match[2] ?? "" });
482
922
  }
483
923
  return { fields, lines: blockLines, start, end };
@@ -509,7 +949,7 @@ var init_metadata_block = __esm({
509
949
  "../file-system-task-provider/src/metadata-style/metadata-block.ts"() {
510
950
  "use strict";
511
951
  init_esm_shims();
512
- METADATA_LINE = /^(?:-[ \t]+)?([^:\n]{1,40}?)[ \t]*:[ \t]*(.*?)[ \t]*\\?[ \t]*$/;
952
+ METADATA_LINE = /^(?:-[ \t]+)?([\p{L}\p{N}][^:\n]{0,39}?)[ \t]*:[ \t]*(.*?)[ \t]*\\?[ \t]*$/u;
513
953
  SECTION_HEADING = /^#{2,}\s/;
514
954
  isHeading = (line) => line.startsWith("#");
515
955
  splitLines = (content) => content.split(/\r?\n/);
@@ -673,89 +1113,365 @@ var init_metadata_style2 = __esm({
673
1113
  }
674
1114
  });
675
1115
 
676
- // ../file-system-task-provider/src/file-system-metrics-adapter.ts
677
- import {
678
- TASK_STATUSES,
679
- TASK_TYPES,
680
- UserStatsSchema
681
- } from "@opentask/taskin-types";
682
- import { promises as fs } from "fs";
683
- import path3 from "path";
684
- function toISOString(date) {
685
- return date.toISOString();
1116
+ // ../file-system-task-provider/src/assignee-identity.ts
1117
+ function classifyAssignee(raw, registry) {
1118
+ const trimmed = raw.trim();
1119
+ if (trimmed === "" || PLACEHOLDER_ASSIGNEES.includes(trimmed.toLowerCase())) {
1120
+ return { kind: "unassigned", raw };
1121
+ }
1122
+ const user = registry.resolveUser(trimmed);
1123
+ if (user) {
1124
+ return { kind: "resolved", raw, user };
1125
+ }
1126
+ const folded = fold(trimmed);
1127
+ const matches = registry.getAllUsers().filter((candidate) => fold(candidate.id) === folded || fold(candidate.name) === folded);
1128
+ const [onlyMatch] = matches;
1129
+ if (onlyMatch && matches.length === 1) {
1130
+ return { kind: "correctable", raw, user: onlyMatch };
1131
+ }
1132
+ return { kind: "unknown", raw };
686
1133
  }
687
- function emptyCodeMetrics() {
688
- return {
689
- linesAdded: 0,
690
- linesRemoved: 0,
691
- netChange: 0,
692
- characters: 0,
693
- filesChanged: 0,
694
- commits: 0
695
- };
1134
+ function validateAssignees(tasks, registry) {
1135
+ const issues = [];
1136
+ for (const task of tasks) {
1137
+ if (task.assignee === void 0) continue;
1138
+ const identity = classifyAssignee(task.assignee, registry);
1139
+ switch (identity.kind) {
1140
+ case "resolved":
1141
+ case "unassigned":
1142
+ break;
1143
+ case "correctable":
1144
+ issues.push({
1145
+ file: task.file,
1146
+ message: `Assignee "${identity.raw.trim()}" is not in the user registry, but folds onto exactly one registered user.`,
1147
+ severity: "warning",
1148
+ suggestion: `Rewrite it as "${identity.user.id}" \u2014 lint --fix does this.`
1149
+ });
1150
+ break;
1151
+ case "unknown":
1152
+ issues.push({
1153
+ file: task.file,
1154
+ message: `Assignee "${identity.raw.trim()}" resolves to nobody in the user registry, so it silently becomes a fabricated temporary user.`,
1155
+ severity: "warning",
1156
+ suggestion: `Register them in ${".taskin/.taskin-users.json"}, or fix the spelling \u2014 too ambiguous for --fix to decide.`
1157
+ });
1158
+ break;
1159
+ default:
1160
+ identity;
1161
+ }
1162
+ }
1163
+ return issues;
696
1164
  }
697
- function emptyTemporalMetrics() {
698
- return {
699
- byDayOfWeek: {
700
- "0": 0,
701
- "1": 0,
702
- "2": 0,
703
- "3": 0,
704
- "4": 0,
705
- "5": 0,
706
- "6": 0
707
- },
708
- byTimeOfDay: { morning: 0, afternoon: 0, evening: 0, night: 0 },
709
- streak: 0,
710
- trend: "stable"
711
- };
1165
+ async function fixAssignees(tasks, registry, io) {
1166
+ const rewritten = [];
1167
+ for (const task of tasks) {
1168
+ if (task.assignee === void 0) continue;
1169
+ const identity = classifyAssignee(task.assignee, registry);
1170
+ if (identity.kind !== "correctable") continue;
1171
+ const content = await io.readFile(task.file);
1172
+ const i18n = getI18n(detectLocale(content));
1173
+ const label = readMetadataField(content, i18n.assignee) === void 0 ? "Assignee" : i18n.assignee;
1174
+ const next = writeMetadataField(content, label, identity.user.id);
1175
+ if (next !== content) {
1176
+ await io.writeFile(task.file, next);
1177
+ rewritten.push(task.file);
1178
+ }
1179
+ }
1180
+ return rewritten;
712
1181
  }
713
- function removeCodeBlocks(content) {
714
- return content.replace(/```[\s\S]*?```/g, "");
1182
+ function isSeededUser(user) {
1183
+ const capitalized = user.id.charAt(0).toUpperCase() + user.id.slice(1);
1184
+ return user.email === `${user.id}@example.com` && user.name === capitalized;
715
1185
  }
716
- function resolvePeriod(period = "week") {
717
- const now = /* @__PURE__ */ new Date();
718
- const until = now;
719
- let since;
720
- switch (period) {
721
- case "day":
722
- since = new Date(Date.now() - MILLISECONDS_PER_DAY);
723
- break;
724
- case "week":
725
- since = new Date(Date.now() - DAYS_PER_WEEK * MILLISECONDS_PER_DAY);
726
- break;
727
- case "month":
728
- since = new Date(Date.now() - 30 * MILLISECONDS_PER_DAY);
729
- break;
730
- case "quarter":
731
- since = new Date(Date.now() - 90 * MILLISECONDS_PER_DAY);
732
- break;
733
- case "year":
734
- since = new Date(Date.now() - 365 * MILLISECONDS_PER_DAY);
735
- break;
736
- case "all":
737
- since = /* @__PURE__ */ new Date(0);
738
- break;
739
- default:
740
- since = new Date(Date.now() - DAYS_PER_WEEK * MILLISECONDS_PER_DAY);
741
- }
742
- return { since, until };
1186
+ function validateSeededUsers(assignees, registry) {
1187
+ const referenced = new Set(
1188
+ assignees.flatMap((raw) => {
1189
+ if (raw === void 0) return [];
1190
+ const identity = classifyAssignee(raw, registry);
1191
+ return identity.kind === "resolved" || identity.kind === "correctable" ? [identity.user.id] : [];
1192
+ })
1193
+ );
1194
+ return registry.getAllUsers().filter((user) => isSeededUser(user) && !referenced.has(user.id)).map((user) => ({
1195
+ file: `${".taskin/.taskin-users.json"} (${user.id})`,
1196
+ message: `User "${user.id}" looks like the placeholder that older taskin versions seeded on init (${user.email}), and no task points at it.`,
1197
+ severity: "warning",
1198
+ suggestion: `Remove it from the registry, or give it the real name and e-mail of whoever it stands for.`
1199
+ }));
743
1200
  }
744
- function calculateActivityFrequency(commits, period) {
745
- const daysInPeriod = {
746
- day: 1,
747
- week: 7,
748
- month: 30,
749
- quarter: 90,
750
- year: 365,
751
- all: 365
752
- // Use 1 year as baseline for 'all'
753
- };
754
- return commits / daysInPeriod[period];
1201
+ var PLACEHOLDER_ASSIGNEES, foldAssignee, fold;
1202
+ var init_assignee_identity = __esm({
1203
+ "../file-system-task-provider/src/assignee-identity.ts"() {
1204
+ "use strict";
1205
+ init_esm_shims();
1206
+ init_i18n();
1207
+ init_metadata_style2();
1208
+ PLACEHOLDER_ASSIGNEES = [
1209
+ "a definir",
1210
+ "to be defined",
1211
+ "nome do responsavel",
1212
+ "nome do respons\xE1vel",
1213
+ "tbd",
1214
+ "-"
1215
+ ];
1216
+ foldAssignee = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
1217
+ fold = foldAssignee;
1218
+ }
1219
+ });
1220
+
1221
+ // ../file-system-task-provider/src/auto-sync.ts
1222
+ function isNonFastForwardError(error2) {
1223
+ if (error2 instanceof Error) {
1224
+ return error2.message.toLowerCase().includes("non-fast-forward");
1225
+ }
1226
+ return false;
755
1227
  }
756
- async function calculateCodeMetrics(gitAnalyzer, username, since, until) {
757
- if (!gitAnalyzer) {
758
- return emptyCodeMetrics();
1228
+ async function syncBeforeCreate(git, config) {
1229
+ if (!config.autoSync || !config.defaultBranch) {
1230
+ if (config.autoSync && !config.defaultBranch) {
1231
+ console.warn("autoSync is enabled but no defaultBranch is configured. Nothing will be synced.");
1232
+ }
1233
+ return;
1234
+ }
1235
+ const fetchOk = await safeCall(() => git.fetch());
1236
+ if (!fetchOk) {
1237
+ throw new Error("Fetch failed. Check your network connection.");
1238
+ }
1239
+ const rebaseOk = await safeCall(() => git.rebase(`origin/${config.defaultBranch}`));
1240
+ if (!rebaseOk) {
1241
+ await git.abortRebase();
1242
+ throw new Error("Rebase failed due to conflict. Aborted.");
1243
+ }
1244
+ }
1245
+ async function safeCall(fn) {
1246
+ try {
1247
+ return await fn();
1248
+ } catch {
1249
+ return false;
1250
+ }
1251
+ }
1252
+ async function attemptPushWithRetry(git, pattern, message, branch, maxAttempts) {
1253
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1254
+ if (attempt > 1) {
1255
+ const fetchOk = await safeCall(() => git.fetch());
1256
+ if (!fetchOk) {
1257
+ throw new Error("Fetch failed during retry.");
1258
+ }
1259
+ const rebaseOk = await safeCall(() => git.rebase(`origin/${branch}`));
1260
+ if (!rebaseOk) {
1261
+ throw new Error("Rebase failed during retry.");
1262
+ }
1263
+ }
1264
+ await git.addAndCommit(pattern, message);
1265
+ try {
1266
+ const pushOk = await git.push(branch);
1267
+ if (pushOk) {
1268
+ return;
1269
+ }
1270
+ } catch (error2) {
1271
+ if (!isNonFastForwardError(error2)) {
1272
+ throw error2;
1273
+ }
1274
+ if (attempt >= maxAttempts) {
1275
+ throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
1276
+ }
1277
+ continue;
1278
+ }
1279
+ if (attempt >= maxAttempts) {
1280
+ throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
1281
+ }
1282
+ }
1283
+ throw new Error(`Push rejected after ${maxAttempts} retries. Exhausted retry limit.`);
1284
+ }
1285
+ async function pushAfterCreate(git, options) {
1286
+ const pattern = `TASKS/task-${options.taskId}-*.md`;
1287
+ const message = appendCiSkipTag(`docs(TASKS): task-${options.taskId} - ${options.title}`, options.ciSkipTag);
1288
+ await attemptPushWithRetry(git, pattern, message, options.defaultBranch, MAX_RETRY_ATTEMPTS);
1289
+ return true;
1290
+ }
1291
+ async function getNextTaskNumberAfterSync(options) {
1292
+ const maxLocal = options.localCount;
1293
+ const maxRemote = options.remoteCount;
1294
+ if (options.autoSync) {
1295
+ const maxNumber = Math.max(maxLocal, maxRemote);
1296
+ return maxNumber + 1;
1297
+ }
1298
+ return maxLocal + 1;
1299
+ }
1300
+ async function createTaskWithSync(git, config, taskOptions) {
1301
+ await syncBeforeCreate(git, config);
1302
+ const nextNumber = await getNextTaskNumberAfterSync({
1303
+ autoSync: config.autoSync,
1304
+ localCount: 0,
1305
+ remoteCount: 0
1306
+ });
1307
+ const taskId = String(nextNumber).padStart(3, "0");
1308
+ if (config.autoSync && config.defaultBranch) {
1309
+ await pushAfterCreate(git, {
1310
+ taskId,
1311
+ title: taskOptions.title,
1312
+ defaultBranch: config.defaultBranch,
1313
+ ciSkipTag: config.ciSkipTag
1314
+ });
1315
+ }
1316
+ return { taskId };
1317
+ }
1318
+ async function squashTaskFileOnDone(git, options) {
1319
+ if (!options.originBranch) {
1320
+ return false;
1321
+ }
1322
+ const currentBranch = await git.getCurrentBranch();
1323
+ const originBranch = options.originBranch;
1324
+ const pattern = `TASKS/task-${options.taskId}-*.md`;
1325
+ const assetsPattern = `TASKS/assets/task-${options.taskId}/`;
1326
+ const coResult = await git.checkoutBranch(originBranch);
1327
+ if (!coResult) {
1328
+ await git.checkoutBranch(currentBranch);
1329
+ return false;
1330
+ }
1331
+ try {
1332
+ const patternsToAdd = [];
1333
+ const fileOk = await git.checkoutFile(options.defaultBranch, pattern);
1334
+ if (fileOk) {
1335
+ patternsToAdd.push(pattern);
1336
+ }
1337
+ const assetsOk = await git.checkoutFile(options.defaultBranch, assetsPattern);
1338
+ if (assetsOk) {
1339
+ patternsToAdd.push(assetsPattern);
1340
+ }
1341
+ if (patternsToAdd.length === 0) {
1342
+ await git.checkoutBranch(currentBranch);
1343
+ return false;
1344
+ }
1345
+ const combinedPattern = patternsToAdd.join(" ");
1346
+ const addOk = await git.addFiles(combinedPattern);
1347
+ if (!addOk) {
1348
+ await git.checkoutBranch(currentBranch);
1349
+ return false;
1350
+ }
1351
+ const message = appendCiSkipTag(`docs(TASKS): task-${options.taskId} - done`, options.ciSkipTag);
1352
+ const commitOk = await git.commit(message);
1353
+ if (!commitOk) {
1354
+ await git.checkoutBranch(currentBranch);
1355
+ return false;
1356
+ }
1357
+ for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
1358
+ if (attempt > 1) {
1359
+ await safeCall(() => git.fetch());
1360
+ await safeCall(() => git.rebase(`origin/${originBranch}`));
1361
+ }
1362
+ const pushOk = await safeCall(() => git.push(originBranch));
1363
+ if (pushOk === true) {
1364
+ await git.checkoutBranch(currentBranch);
1365
+ return true;
1366
+ }
1367
+ if (attempt >= MAX_RETRY_ATTEMPTS) {
1368
+ await git.checkoutBranch(currentBranch);
1369
+ throw new Error(`Squash push rejected after ${MAX_RETRY_ATTEMPTS} retries.`);
1370
+ }
1371
+ }
1372
+ await git.checkoutBranch(currentBranch);
1373
+ throw new Error(`Squash push rejected after ${MAX_RETRY_ATTEMPTS} retries.`);
1374
+ } catch (error2) {
1375
+ await git.checkoutBranch(currentBranch);
1376
+ if (error2 instanceof Error) {
1377
+ throw error2;
1378
+ }
1379
+ return false;
1380
+ }
1381
+ }
1382
+ var MAX_RETRY_ATTEMPTS;
1383
+ var init_auto_sync = __esm({
1384
+ "../file-system-task-provider/src/auto-sync.ts"() {
1385
+ "use strict";
1386
+ init_esm_shims();
1387
+ init_src();
1388
+ MAX_RETRY_ATTEMPTS = 3;
1389
+ }
1390
+ });
1391
+
1392
+ // ../file-system-task-provider/src/file-system-metrics-adapter.ts
1393
+ import {
1394
+ TASK_STATUSES,
1395
+ TASK_TYPES,
1396
+ UserStatsSchema
1397
+ } from "@opentask/taskin-types";
1398
+ import { promises as fs } from "fs";
1399
+ import path3 from "path";
1400
+ function toISOString(date) {
1401
+ return date.toISOString();
1402
+ }
1403
+ function emptyCodeMetrics() {
1404
+ return {
1405
+ linesAdded: 0,
1406
+ linesRemoved: 0,
1407
+ netChange: 0,
1408
+ characters: 0,
1409
+ filesChanged: 0,
1410
+ commits: 0
1411
+ };
1412
+ }
1413
+ function emptyTemporalMetrics() {
1414
+ return {
1415
+ byDayOfWeek: {
1416
+ "0": 0,
1417
+ "1": 0,
1418
+ "2": 0,
1419
+ "3": 0,
1420
+ "4": 0,
1421
+ "5": 0,
1422
+ "6": 0
1423
+ },
1424
+ byTimeOfDay: { morning: 0, afternoon: 0, evening: 0, night: 0 },
1425
+ streak: 0,
1426
+ trend: "stable"
1427
+ };
1428
+ }
1429
+ function removeCodeBlocks(content) {
1430
+ return content.replace(/```[\s\S]*?```/g, "");
1431
+ }
1432
+ function resolvePeriod(period = "week") {
1433
+ const now = /* @__PURE__ */ new Date();
1434
+ const until = now;
1435
+ let since;
1436
+ switch (period) {
1437
+ case "day":
1438
+ since = new Date(Date.now() - MILLISECONDS_PER_DAY);
1439
+ break;
1440
+ case "week":
1441
+ since = new Date(Date.now() - DAYS_PER_WEEK * MILLISECONDS_PER_DAY);
1442
+ break;
1443
+ case "month":
1444
+ since = new Date(Date.now() - 30 * MILLISECONDS_PER_DAY);
1445
+ break;
1446
+ case "quarter":
1447
+ since = new Date(Date.now() - 90 * MILLISECONDS_PER_DAY);
1448
+ break;
1449
+ case "year":
1450
+ since = new Date(Date.now() - 365 * MILLISECONDS_PER_DAY);
1451
+ break;
1452
+ case "all":
1453
+ since = /* @__PURE__ */ new Date(0);
1454
+ break;
1455
+ default:
1456
+ since = new Date(Date.now() - DAYS_PER_WEEK * MILLISECONDS_PER_DAY);
1457
+ }
1458
+ return { since, until };
1459
+ }
1460
+ function calculateActivityFrequency(commits, period) {
1461
+ const daysInPeriod = {
1462
+ day: 1,
1463
+ week: 7,
1464
+ month: 30,
1465
+ quarter: 90,
1466
+ year: 365,
1467
+ all: 365
1468
+ // Use 1 year as baseline for 'all'
1469
+ };
1470
+ return commits / daysInPeriod[period];
1471
+ }
1472
+ async function calculateCodeMetrics(gitAnalyzer, username, since, until) {
1473
+ if (!gitAnalyzer) {
1474
+ return emptyCodeMetrics();
759
1475
  }
760
1476
  try {
761
1477
  const commits = await gitAnalyzer.getCommits({
@@ -1116,56 +1832,6 @@ var init_file_system_metrics_adapter = __esm({
1116
1832
  }
1117
1833
  });
1118
1834
 
1119
- // ../file-system-task-provider/src/i18n.ts
1120
- function getI18n(locale = "en-US") {
1121
- return i18nConfig[locale];
1122
- }
1123
- function detectLocale(content) {
1124
- if (content.includes("## Descri\xE7\xE3o") || content.includes("## Tipo") || content.includes("## Respons\xE1vel") || content.includes("## Tarefas")) {
1125
- return "pt-BR";
1126
- }
1127
- return "en-US";
1128
- }
1129
- var i18nConfig;
1130
- var init_i18n = __esm({
1131
- "../file-system-task-provider/src/i18n.ts"() {
1132
- "use strict";
1133
- init_esm_shims();
1134
- i18nConfig = {
1135
- "en-US": {
1136
- status: "Status",
1137
- type: "Type",
1138
- assignee: "Assignee",
1139
- description: "Description",
1140
- tasks: "Tasks",
1141
- notes: "Notes",
1142
- defaultAssignee: "To be defined",
1143
- descriptionPlaceholder: "Add task description here...",
1144
- notesPlaceholder: "Add any relevant notes or links here.",
1145
- priority: "Priority",
1146
- group: "Group",
1147
- groupName: "GroupName",
1148
- difficulty: "Difficulty"
1149
- },
1150
- "pt-BR": {
1151
- status: "Status",
1152
- type: "Tipo",
1153
- assignee: "Respons\xE1vel",
1154
- description: "Descri\xE7\xE3o",
1155
- tasks: "Tarefas",
1156
- notes: "Notas",
1157
- defaultAssignee: "A definir",
1158
- descriptionPlaceholder: "Adicione a descri\xE7\xE3o da tarefa aqui...",
1159
- notesPlaceholder: "Adicione notas ou links relevantes aqui.",
1160
- priority: "Prioridade",
1161
- group: "Grupo",
1162
- groupName: "NomeGrupo",
1163
- difficulty: "Dificuldade"
1164
- }
1165
- };
1166
- }
1167
- });
1168
-
1169
1835
  // ../file-system-task-provider/src/task-validator.ts
1170
1836
  var task_validator_exports = {};
1171
1837
  __export(task_validator_exports, {
@@ -1343,7 +2009,7 @@ var init_task_validator = __esm({
1343
2009
  });
1344
2010
 
1345
2011
  // ../file-system-task-provider/src/user-registry.ts
1346
- import { execSync } from "child_process";
2012
+ import { execSync as execSync3 } from "child_process";
1347
2013
  import { createHash } from "crypto";
1348
2014
  import { promises as fs2 } from "fs";
1349
2015
  import path4 from "path";
@@ -1460,7 +2126,7 @@ var init_user_registry = __esm({
1460
2126
  }
1461
2127
  readGitConfig(key) {
1462
2128
  try {
1463
- return execSync(`git config ${key}`, {
2129
+ return execSync3(`git config ${key}`, {
1464
2130
  encoding: "utf-8",
1465
2131
  stdio: "pipe"
1466
2132
  }).trim();
@@ -1636,7 +2302,7 @@ var init_file_system_task_provider = __esm({
1636
2302
  "../file-system-task-provider/src/file-system-task-provider.ts"() {
1637
2303
  "use strict";
1638
2304
  init_esm_shims();
1639
- init_src();
2305
+ init_src2();
1640
2306
  init_assignee_identity();
1641
2307
  init_i18n();
1642
2308
  init_metadata_style2();
@@ -2045,7 +2711,7 @@ __export(src_exports, {
2045
2711
  validateUsersFileLocation: () => validateUsersFileLocation,
2046
2712
  writeMetadataField: () => writeMetadataField
2047
2713
  });
2048
- var init_src2 = __esm({
2714
+ var init_src3 = __esm({
2049
2715
  "../file-system-task-provider/src/index.ts"() {
2050
2716
  "use strict";
2051
2717
  init_esm_shims();
@@ -2068,6 +2734,7 @@ import { Command } from "commander";
2068
2734
 
2069
2735
  // src/commands/config.ts
2070
2736
  init_esm_shims();
2737
+ init_src();
2071
2738
  import chalk2 from "chalk";
2072
2739
  import inquirer from "inquirer";
2073
2740
 
@@ -2114,9 +2781,15 @@ function warning(message) {
2114
2781
 
2115
2782
  // src/lib/config-manager.ts
2116
2783
  init_esm_shims();
2784
+ init_src();
2117
2785
  import { TaskinConfigSchema } from "@opentask/taskin-types";
2118
2786
  import { existsSync, readFileSync, writeFileSync } from "fs";
2119
2787
  import { join } from "path";
2788
+ var DEFAULT_AUTOMATION_CONFIG = {
2789
+ level: "assisted",
2790
+ autoSync: true,
2791
+ ciSkipTag: DEFAULT_CI_SKIP_TAG
2792
+ };
2120
2793
  function getAutomationBehavior(level, commits) {
2121
2794
  const presets = {
2122
2795
  manual: {
@@ -2170,6 +2843,9 @@ ${result.error.message}`);
2170
2843
  }
2171
2844
  /**
2172
2845
  * Save configuration to .taskin.json
2846
+ *
2847
+ * Takes the *input* shape: the schema fills in what it has defaults for, so
2848
+ * a caller need not spell out `automation.ciSkipTag` to save a config.
2173
2849
  */
2174
2850
  saveConfig(config) {
2175
2851
  const validated = TaskinConfigSchema.parse(config);
@@ -2193,7 +2869,7 @@ ${result.error.message}`);
2193
2869
  setAutomationLevel(level) {
2194
2870
  const config = this.loadConfig();
2195
2871
  config.automation = {
2196
- autoSync: true,
2872
+ ...DEFAULT_AUTOMATION_CONFIG,
2197
2873
  ...config.automation,
2198
2874
  level
2199
2875
  };
@@ -2205,11 +2881,33 @@ ${result.error.message}`);
2205
2881
  getAutomationConfig() {
2206
2882
  try {
2207
2883
  const config = this.loadConfig();
2208
- return config.automation ?? { level: "assisted", autoSync: true };
2884
+ return config.automation ?? DEFAULT_AUTOMATION_CONFIG;
2209
2885
  } catch {
2210
- return { level: "assisted", autoSync: true };
2886
+ return DEFAULT_AUTOMATION_CONFIG;
2211
2887
  }
2212
2888
  }
2889
+ /**
2890
+ * Tag appended to the commits Taskin writes on its own, so a status change
2891
+ * does not trigger the project's pipeline.
2892
+ *
2893
+ * Returns `[skip ci]` when unconfigured. An empty string is a real answer —
2894
+ * it means the project wants CI to run — so it is returned as-is.
2895
+ */
2896
+ getCiSkipTag() {
2897
+ return this.getAutomationConfig().ciSkipTag ?? DEFAULT_CI_SKIP_TAG;
2898
+ }
2899
+ /**
2900
+ * Set the CI-skip tag, preserving the rest of the automation block.
2901
+ */
2902
+ setCiSkipTag(ciSkipTag) {
2903
+ const config = this.loadConfig();
2904
+ config.automation = {
2905
+ ...DEFAULT_AUTOMATION_CONFIG,
2906
+ ...config.automation,
2907
+ ciSkipTag
2908
+ };
2909
+ this.saveConfig(config);
2910
+ }
2213
2911
  /**
2214
2912
  * Set automation configuration
2215
2913
  */
@@ -2227,7 +2925,8 @@ ${result.error.message}`);
2227
2925
  ...getAutomationBehavior(automation.level, automation.commits),
2228
2926
  defaultBranch: automation.defaultBranch,
2229
2927
  autoSync: automation.autoSync,
2230
- originBranch: automation.originBranch
2928
+ originBranch: automation.originBranch,
2929
+ ciSkipTag: automation.ciSkipTag ?? DEFAULT_CI_SKIP_TAG
2231
2930
  };
2232
2931
  }
2233
2932
  /**
@@ -2339,6 +3038,14 @@ var defineCommand = (config) => {
2339
3038
  init_esm_shims();
2340
3039
 
2341
3040
  // src/commands/config.ts
3041
+ var NO_CI_SKIP_TAG_KEYWORD = "none";
3042
+ function normalizeCiSkipTagInput(input) {
3043
+ const trimmed = input.trim();
3044
+ return trimmed.toLowerCase() === NO_CI_SKIP_TAG_KEYWORD ? "" : trimmed;
3045
+ }
3046
+ function describeCiSkipTag(tag) {
3047
+ return tag.length === 0 ? "no tag \u2014 CI runs on status commits" : tag;
3048
+ }
2342
3049
  var configCommand = defineCommand({
2343
3050
  name: "config",
2344
3051
  description: "\u2699\uFE0F Configure Taskin settings",
@@ -2358,6 +3065,10 @@ var configCommand = defineCommand({
2358
3065
  {
2359
3066
  flags: "--notification-events <events>",
2360
3067
  description: "Comma-separated events (task:start,task:done,task:review)"
3068
+ },
3069
+ {
3070
+ flags: "--ci-skip-tag <tag>",
3071
+ description: `Tag appended to Taskin's own commits so they skip CI (default "${DEFAULT_CI_SKIP_TAG}"; "none" to run CI)`
2361
3072
  }
2362
3073
  ],
2363
3074
  handler: async (options) => {
@@ -2375,8 +3086,12 @@ async function handleConfigCommand(options) {
2375
3086
  await setAutomationLevel(configManager, options.level);
2376
3087
  return;
2377
3088
  }
2378
- if (options["discord-webhook"]) {
2379
- await setDiscordNotification(configManager, options["discord-webhook"], options["notification-events"]);
3089
+ if (options.ciSkipTag !== void 0) {
3090
+ setCiSkipTag(configManager, options.ciSkipTag);
3091
+ return;
3092
+ }
3093
+ if (options.discordWebhook) {
3094
+ await setDiscordNotification(configManager, options.discordWebhook, options.notificationEvents);
2380
3095
  return;
2381
3096
  }
2382
3097
  await interactiveConfig(configManager);
@@ -2397,7 +3112,8 @@ async function showConfiguration(configManager) {
2397
3112
  ` Auto-commit status changes: ${behavior.autoCommitStatusChange ? chalk2.green("\u2713 Yes") : chalk2.red("\u2717 No")}`
2398
3113
  );
2399
3114
  console.log(` Auto-commit on pause: ${behavior.autoCommitPause ? chalk2.green("\u2713 Yes") : chalk2.red("\u2717 No")}`);
2400
- console.log(` Auto-commit on finish: ${behavior.autoCommitFinish ? chalk2.green("\u2713 Yes") : chalk2.red("\u2717 No")}
3115
+ console.log(` Auto-commit on finish: ${behavior.autoCommitFinish ? chalk2.green("\u2713 Yes") : chalk2.red("\u2717 No")}`);
3116
+ console.log(` CI skip tag: ${chalk2.cyan(describeCiSkipTag(configManager.getCiSkipTag()))}
2401
3117
  `);
2402
3118
  console.log(chalk2.bold("\u{1F514} Notifications"));
2403
3119
  const notifications = configManager.getNotifications();
@@ -2432,6 +3148,24 @@ async function showConfiguration(configManager) {
2432
3148
  process.exit(1);
2433
3149
  }
2434
3150
  }
3151
+ function warnAboutUnrecognizedCiSkipTag(tag) {
3152
+ if (tag.length === 0 || isRecognizedCiSkipTag(tag)) return;
3153
+ console.log();
3154
+ console.log(chalk2.yellow("\u26A0\uFE0F This tag is not one GitHub, GitLab or Bitbucket documents."));
3155
+ if (tag.replace(/\s+/g, "").toLowerCase() === "[skip-ci]") {
3156
+ console.log(chalk2.dim(' "[skip-ci]" with a hyphen is not recognized anywhere \u2014 it triggers CI.'));
3157
+ console.log(chalk2.dim(' Did you mean "[skip ci]", with a space?'));
3158
+ }
3159
+ console.log(chalk2.dim(` Documented tags: ${CI_SKIP_TAGS.join(", ")}`));
3160
+ console.log(chalk2.dim(" Keeping it anyway \u2014 a self-hosted pipeline can match whatever it likes."));
3161
+ }
3162
+ function setCiSkipTag(configManager, rawTag) {
3163
+ printHeader("Configure CI Skip Tag", "\u2699\uFE0F");
3164
+ const tag = normalizeCiSkipTagInput(rawTag);
3165
+ configManager.setCiSkipTag(tag);
3166
+ success(`CI skip tag set to ${colors.highlight(describeCiSkipTag(tag))}`);
3167
+ warnAboutUnrecognizedCiSkipTag(tag);
3168
+ }
2435
3169
  async function setDiscordNotification(configManager, webhookUrl, eventsFlag) {
2436
3170
  printHeader("Configure Discord Notification", "\u{1F514}");
2437
3171
  const events = eventsFlag ? eventsFlag.split(",").map((e) => e.trim()) : ["task:start", "task:done"];
@@ -2487,6 +3221,7 @@ async function interactiveConfig(configManager) {
2487
3221
  message: "What would you like to configure?",
2488
3222
  choices: [
2489
3223
  { name: "\u{1F916} Automation level", value: "automation" },
3224
+ { name: "\u23ED\uFE0F CI skip tag", value: "ciSkipTag" },
2490
3225
  { name: "\u{1F514} Discord notification", value: "discord" },
2491
3226
  { name: "\u{1F514} Telegram notification", value: "telegram" }
2492
3227
  ]
@@ -2494,6 +3229,8 @@ async function interactiveConfig(configManager) {
2494
3229
  ]);
2495
3230
  if (section === "automation") {
2496
3231
  await configureAutomation(configManager);
3232
+ } else if (section === "ciSkipTag") {
3233
+ await configureCiSkipTag(configManager);
2497
3234
  } else if (section === "discord") {
2498
3235
  await configureDiscordNotification(configManager);
2499
3236
  } else if (section === "telegram") {
@@ -2545,6 +3282,40 @@ async function configureAutomation(configManager) {
2545
3282
  console.log(chalk2.dim(` Auto-commit on pause: ${behavior.autoCommitPause ? "\u2713" : "\u2717"}`));
2546
3283
  console.log(chalk2.dim(` Auto-commit on finish: ${behavior.autoCommitFinish ? "\u2713" : "\u2717"}`));
2547
3284
  }
3285
+ async function configureCiSkipTag(configManager) {
3286
+ printHeader("Configure CI Skip Tag", "\u23ED\uFE0F");
3287
+ const current = configManager.getCiSkipTag();
3288
+ console.log(`Current tag: ${chalk2.cyan(describeCiSkipTag(current))}
3289
+ `);
3290
+ console.log(chalk2.dim("Taskin appends this to the commits it writes itself \u2014 status changes and"));
3291
+ console.log(chalk2.dim("task files \u2014 so they do not trigger your pipeline.\n"));
3292
+ const { choice } = await inquirer.prompt([
3293
+ {
3294
+ type: "list",
3295
+ name: "choice",
3296
+ message: "Tag for Taskin commits:",
3297
+ default: current.length === 0 ? NO_CI_SKIP_TAG_KEYWORD : current,
3298
+ choices: [
3299
+ { name: `${CI_SKIP_TAGS[0]} \u2014 GitHub, GitLab and Bitbucket (recommended)`, value: CI_SKIP_TAGS[0] },
3300
+ { name: `${CI_SKIP_TAGS[1]} \u2014 GitHub, GitLab and Bitbucket`, value: CI_SKIP_TAGS[1] },
3301
+ { name: `${CI_SKIP_TAGS[2]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[2] },
3302
+ { name: `${CI_SKIP_TAGS[3]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[3] },
3303
+ { name: `${CI_SKIP_TAGS[4]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[4] },
3304
+ { name: "none \u2014 do not mark the commits, let CI run", value: NO_CI_SKIP_TAG_KEYWORD },
3305
+ { name: "custom\u2026 \u2014 another CI (Azure DevOps uses ***NO_CI***)", value: "custom" }
3306
+ ]
3307
+ }
3308
+ ]);
3309
+ const tag = choice === "custom" ? (await inquirer.prompt([
3310
+ {
3311
+ type: "input",
3312
+ name: "customTag",
3313
+ message: "Tag to append:",
3314
+ default: current
3315
+ }
3316
+ ])).customTag : choice;
3317
+ setCiSkipTag(configManager, tag);
3318
+ }
2548
3319
  async function configureDiscordNotification(configManager) {
2549
3320
  printHeader("Configure Discord Notification", "\u{1F514}");
2550
3321
  const notifications = configManager.getNotifications() ?? {};
@@ -3000,1220 +3771,640 @@ var TaskWebSocketServer = class {
3000
3771
  await this.handlePauseRequest(client, message);
3001
3772
  break;
3002
3773
  case "ping":
3003
- this.sendToClient(client.id, { type: "pong" });
3004
- break;
3005
- default:
3006
- this.sendToClient(client.id, {
3007
- type: "error",
3008
- payload: { message: `Unknown message type: ${message.type}` }
3009
- });
3010
- }
3011
- } catch (error2) {
3012
- this.log("Error handling message:", error2);
3013
- this.sendToClient(client.id, {
3014
- type: "error",
3015
- payload: {
3016
- message: error2 instanceof Error ? error2.message : "Failed to process message"
3017
- }
3018
- });
3019
- }
3020
- }
3021
- /**
3022
- * Reads the task id out of an incoming message.
3023
- *
3024
- * Ids on the wire are untrusted strings: a branded `TaskId` is only worth
3025
- * something if the boundary that produces it actually validates. Answers the
3026
- * client with a clear error instead of letting a ZodError leak out of the
3027
- * generic catch.
3028
- */
3029
- readTaskId(client, message, field = "taskId") {
3030
- const raw = message.payload?.[field];
3031
- const parsed = typeof raw === "string" ? TaskIdSchema.safeParse(raw) : void 0;
3032
- if (!parsed?.success) {
3033
- this.sendToClient(client.id, {
3034
- type: "error",
3035
- payload: { message: `Invalid task id in '${message.type}' request` },
3036
- requestId: message.requestId
3037
- });
3038
- return void 0;
3039
- }
3040
- return parsed.data;
3041
- }
3042
- /**
3043
- * Handle list request
3044
- */
3045
- async handleListRequest(client, message) {
3046
- const tasks = await this.taskProvider.getAllTasks();
3047
- const [first] = tasks;
3048
- if (first) {
3049
- this.log("[WS Server] Sending", tasks.length, "tasks");
3050
- this.log("[WS Server] First task assignee:", first.assignee);
3051
- }
3052
- this.sendToClient(client.id, {
3053
- type: "tasks",
3054
- payload: tasks,
3055
- requestId: message.requestId
3056
- });
3057
- }
3058
- /**
3059
- * Handle find request
3060
- */
3061
- async handleFindRequest(client, message) {
3062
- const taskId = this.readTaskId(client, message);
3063
- if (!taskId)
3064
- return;
3065
- const task = await this.taskProvider.findTask(taskId);
3066
- this.sendToClient(client.id, {
3067
- type: "task:found",
3068
- payload: task,
3069
- requestId: message.requestId
3070
- });
3071
- }
3072
- /**
3073
- * Handle update request
3074
- */
3075
- async handleUpdateRequest(client, message) {
3076
- const taskId = this.readTaskId(client, message, "id");
3077
- if (!taskId)
3078
- return;
3079
- const stored = await this.taskProvider.findTask(taskId);
3080
- if (!stored) {
3081
- this.sendToClient(client.id, {
3082
- type: "error",
3083
- payload: { message: `Task ${taskId} not found` },
3084
- requestId: message.requestId
3085
- });
3086
- return;
3087
- }
3088
- const outcome = applyTaskUpdate(stored, message.payload);
3089
- if (!outcome.ok) {
3090
- this.sendToClient(client.id, {
3091
- type: "error",
3092
- payload: { message: outcome.message },
3093
- requestId: message.requestId
3094
- });
3095
- return;
3096
- }
3097
- await this.taskProvider.updateTask(outcome.task);
3098
- this.broadcast({
3099
- type: "task:updated",
3100
- payload: outcome.task
3101
- });
3102
- }
3103
- /**
3104
- * Handle start task request
3105
- */
3106
- async handleStartRequest(client, message) {
3107
- const taskId = this.readTaskId(client, message);
3108
- if (!taskId)
3109
- return;
3110
- const task = await this.taskManager.startTask(taskId);
3111
- this.broadcast({
3112
- type: "task:updated",
3113
- payload: task
3114
- });
3115
- }
3116
- /**
3117
- * Handle finish task request
3118
- */
3119
- async handleFinishRequest(client, message) {
3120
- const taskId = this.readTaskId(client, message);
3121
- if (!taskId)
3122
- return;
3123
- const task = await this.taskManager.finishTask(taskId);
3124
- this.broadcast({
3125
- type: "task:updated",
3126
- payload: task
3127
- });
3128
- }
3129
- /**
3130
- * Handle pause task request
3131
- */
3132
- async handlePauseRequest(client, message) {
3133
- const taskId = this.readTaskId(client, message);
3134
- if (!taskId)
3135
- return;
3136
- const task = await this.taskManager.pauseTask(taskId);
3137
- this.broadcast({
3138
- type: "task:updated",
3139
- payload: task
3140
- });
3141
- }
3142
- /**
3143
- * Start heartbeat to check client connections
3144
- */
3145
- startHeartbeat() {
3146
- this.heartbeatInterval = setInterval(() => {
3147
- this.clients.forEach((client) => {
3148
- if (!client.isAlive) {
3149
- this.log(`Client ${client.id} timeout, terminating`);
3150
- client.ws.terminate();
3151
- this.clients.delete(client.id);
3152
- return;
3153
- }
3154
- client.isAlive = false;
3155
- client.ws.ping();
3156
- });
3157
- }, this.options.heartbeatInterval);
3158
- }
3159
- /**
3160
- * Stop heartbeat
3161
- */
3162
- stopHeartbeat() {
3163
- if (this.heartbeatInterval) {
3164
- clearInterval(this.heartbeatInterval);
3165
- this.heartbeatInterval = null;
3166
- }
3167
- }
3168
- /**
3169
- * Debug logging
3170
- */
3171
- log(...args) {
3172
- if (this.options.debug) {
3173
- console.log("[TaskWebSocketServer]", ...args);
3174
- }
3175
- }
3176
- };
3177
-
3178
- // node_modules/@opentask/taskin-task-server-ws/dist/task-server-ws.mock.js
3179
- init_esm_shims();
3180
- import { parseTaskId } from "@opentask/taskin-types";
3181
-
3182
- // node_modules/@opentask/taskin-task-server-ws/dist/task-server-ws.types.js
3183
- init_esm_shims();
3184
-
3185
- // src/commands/dashboard.ts
3186
- init_src();
3187
- import chalk4 from "chalk";
3188
- import express from "express";
3189
- import { createServer } from "http";
3190
- import path8 from "path";
3191
- import { fileURLToPath as fileURLToPath2 } from "url";
3192
-
3193
- // src/lib/provider-factory/index.ts
3194
- init_esm_shims();
3195
-
3196
- // src/lib/provider-factory/provider-factory.ts
3197
- init_esm_shims();
3198
- import path7 from "path";
3199
-
3200
- // src/lib/notification/env-resolver.ts
3201
- init_esm_shims();
3202
- import { readFileSync as readFileSync2 } from "fs";
3203
- import { join as join2 } from "path";
3204
- var ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
3205
- function loadDotEnv(dir) {
3206
- try {
3207
- const envPath = join2(dir ?? process.cwd(), ".env");
3208
- const content = readFileSync2(envPath, "utf-8");
3209
- for (const line of content.split("\n")) {
3210
- const trimmed = line.trim();
3211
- if (!trimmed || trimmed.startsWith("#")) continue;
3212
- const eqIndex = trimmed.indexOf("=");
3213
- if (eqIndex === -1) continue;
3214
- const key = trimmed.slice(0, eqIndex).trim();
3215
- let value = trimmed.slice(eqIndex + 1).trim();
3216
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3217
- value = value.slice(1, -1);
3218
- }
3219
- if (!process.env[key]) {
3220
- process.env[key] = value;
3221
- }
3222
- }
3223
- } catch {
3224
- }
3225
- }
3226
- function resolveEnvVars(value) {
3227
- return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
3228
- return process.env[varName] ?? "";
3229
- });
3230
- }
3231
-
3232
- // src/lib/provider-registry/index.ts
3233
- init_esm_shims();
3234
-
3235
- // src/lib/provider-registry/provider-registry.ts
3236
- init_esm_shims();
3237
- var AVAILABLE_PROVIDERS = [
3238
- {
3239
- id: "fs",
3240
- name: "\u{1F4C1} File System",
3241
- description: "Store tasks as Markdown files in a local TASKS/ directory",
3242
- packageName: "@opentask/taskin-file-system-provider",
3243
- configSchema: {
3244
- required: ["tasksDir"],
3245
- properties: {
3246
- tasksDir: {
3247
- type: "string",
3248
- description: "Directory to store task files"
3249
- },
3250
- metadataStyle: {
3251
- type: "string",
3252
- description: "Marking of the metadata block for new files (list | hard-break | plain)"
3253
- }
3254
- }
3255
- },
3256
- status: "stable"
3257
- },
3258
- {
3259
- id: "redmine",
3260
- name: "\u{1F534} Redmine",
3261
- description: "Sync tasks with Redmine issues via REST API",
3262
- packageName: "@opentask/taskin-redmine-provider",
3263
- configSchema: {
3264
- required: ["apiUrl", "apiKey", "projectId"],
3265
- properties: {
3266
- apiUrl: {
3267
- type: "string",
3268
- description: "Redmine server URL (e.g., https://redmine.example.com)"
3269
- },
3270
- apiKey: {
3271
- type: "string",
3272
- description: "Your Redmine API key",
3273
- secret: true
3274
- },
3275
- projectId: {
3276
- type: "string",
3277
- description: "Project identifier or ID"
3278
- }
3279
- }
3280
- },
3281
- status: "coming-soon"
3282
- },
3283
- {
3284
- id: "jira",
3285
- name: "\u{1F535} Jira",
3286
- description: "Sync tasks with Jira issues via REST API",
3287
- packageName: "@opentask/taskin-jira-provider",
3288
- configSchema: {
3289
- required: ["apiUrl", "email", "apiToken", "projectKey"],
3290
- properties: {
3291
- apiUrl: {
3292
- type: "string",
3293
- description: "Jira server URL (e.g., https://company.atlassian.net)"
3294
- },
3295
- email: {
3296
- type: "string",
3297
- description: "Your Atlassian account email"
3298
- },
3299
- apiToken: {
3300
- type: "string",
3301
- description: "Your Jira API token",
3302
- secret: true
3303
- },
3304
- projectKey: {
3305
- type: "string",
3306
- description: "Project key (e.g., PROJ)"
3307
- }
3308
- }
3309
- },
3310
- status: "coming-soon"
3311
- },
3312
- {
3313
- id: "github",
3314
- name: "\u{1F419} GitHub Issues",
3315
- description: "Sync tasks with GitHub Issues",
3316
- packageName: "@opentask/taskin-github-provider",
3317
- configSchema: {
3318
- required: ["owner", "repo", "token"],
3319
- properties: {
3320
- owner: {
3321
- type: "string",
3322
- description: "Repository owner (username or organization)"
3323
- },
3324
- repo: {
3325
- type: "string",
3326
- description: "Repository name"
3327
- },
3328
- token: {
3329
- type: "string",
3330
- description: "GitHub Personal Access Token",
3331
- secret: true
3332
- }
3333
- }
3334
- },
3335
- status: "coming-soon"
3336
- }
3337
- ];
3338
- function getProviderById(id) {
3339
- return AVAILABLE_PROVIDERS.find((p) => p.id === id);
3340
- }
3341
- function getAllProviders() {
3342
- return AVAILABLE_PROVIDERS;
3343
- }
3344
-
3345
- // src/lib/provider-registry/provider-registry.types.ts
3346
- init_esm_shims();
3347
-
3348
- // src/lib/provider-factory/provider-factory.ts
3349
- function expandProviderConfig(config) {
3350
- const expanded = {};
3351
- for (const [key, value] of Object.entries(config)) {
3352
- expanded[key] = typeof value === "string" ? resolveEnvVars(value) : value;
3353
- }
3354
- return expanded;
3355
- }
3356
- var buildFileSystemProvider = async ({ projectRoot, providerConfig, tasksDirOverride }) => {
3357
- const { FileSystemTaskProvider: FileSystemTaskProvider2, isMetadataStyleId: isMetadataStyleId2, UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src2(), src_exports));
3358
- const configuredTasksDir = typeof providerConfig.tasksDir === "string" ? providerConfig.tasksDir : "TASKS";
3359
- const tasksDir = path7.resolve(projectRoot, tasksDirOverride ?? configuredTasksDir);
3360
- const userRegistry = new UserRegistry2({ taskinDir: path7.join(projectRoot, ".taskin") });
3361
- const metadataStyle = isMetadataStyleId2(providerConfig.metadataStyle) ? providerConfig.metadataStyle : void 0;
3362
- const convertMetadataStyleTo = isMetadataStyleId2(providerConfig.convertMetadataStyleTo) ? providerConfig.convertMetadataStyleTo : void 0;
3363
- const provider = new FileSystemTaskProvider2(tasksDir, userRegistry, void 0, void 0, {
3364
- ...metadataStyle !== void 0 && { metadataStyle },
3365
- ...convertMetadataStyleTo !== void 0 && { convertMetadataStyleTo }
3366
- });
3367
- return { provider, userRegistry };
3368
- };
3369
- var PROVIDER_BUILDERS = {
3370
- fs: buildFileSystemProvider
3371
- };
3372
- function unknownProviderError(providerType, builders) {
3373
- const known = getProviderById(providerType);
3374
- if (known) {
3375
- return new Error(
3376
- `Provider "${providerType}" (${known.name}) is configured in .taskin.json but has no implementation yet (status: ${known.status}).
3377
- Install/await ${known.packageName}, or change provider.type.`
3378
- );
3379
- }
3380
- const buildable = Object.keys(builders).join(", ");
3381
- const listed = getAllProviders().map((provider) => provider.id).join(", ");
3382
- return new Error(
3383
- `Unknown provider.type "${providerType}" in .taskin.json.
3384
- Usable now: ${buildable}. Known ids: ${listed}.`
3385
- );
3386
- }
3387
- async function resolveTaskProvider(options = {}, builders = PROVIDER_BUILDERS) {
3388
- const projectRoot = path7.resolve(options.cwd ?? process.cwd());
3389
- loadDotEnv(projectRoot);
3390
- const config = new ConfigManager(projectRoot).loadConfig();
3391
- const providerType = config.provider.type;
3392
- const build = builders[providerType];
3393
- if (!build) {
3394
- throw unknownProviderError(providerType, builders);
3395
- }
3396
- const context = {
3397
- projectRoot,
3398
- providerConfig: { ...expandProviderConfig(config.provider.config), ...options.configOverrides },
3399
- ...options.tasksDir !== void 0 && { tasksDirOverride: options.tasksDir }
3400
- };
3401
- const { provider, userRegistry } = await build(context);
3402
- await userRegistry.load();
3403
- return { provider, userRegistry, projectRoot, providerType };
3404
- }
3405
-
3406
- // src/lib/provider-factory/provider-factory.types.ts
3407
- init_esm_shims();
3408
-
3409
- // src/commands/dashboard.ts
3410
- var __filename2 = fileURLToPath2(import.meta.url);
3411
- var __dirname2 = path8.dirname(__filename2);
3412
- async function startHttpServer(app, startPort, host, maxAttempts = 10) {
3413
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
3414
- const tryPort = startPort + attempt;
3415
- try {
3416
- const server = createServer(app);
3417
- await new Promise((resolve, reject) => {
3418
- server.once("error", reject);
3419
- server.listen(tryPort, host, () => resolve());
3420
- });
3421
- return { server, port: tryPort };
3422
- } catch (err) {
3423
- const nodeErr = err;
3424
- if (nodeErr.code !== "EADDRINUSE") {
3425
- throw err;
3426
- }
3427
- if (attempt < maxAttempts - 1) {
3428
- info(`Port ${tryPort} is in use, trying port ${tryPort + 1}...`);
3774
+ this.sendToClient(client.id, { type: "pong" });
3775
+ break;
3776
+ default:
3777
+ this.sendToClient(client.id, {
3778
+ type: "error",
3779
+ payload: { message: `Unknown message type: ${message.type}` }
3780
+ });
3429
3781
  }
3782
+ } catch (error2) {
3783
+ this.log("Error handling message:", error2);
3784
+ this.sendToClient(client.id, {
3785
+ type: "error",
3786
+ payload: {
3787
+ message: error2 instanceof Error ? error2.message : "Failed to process message"
3788
+ }
3789
+ });
3430
3790
  }
3431
3791
  }
3432
- throw new Error(
3433
- `Could not find an available port after ${maxAttempts} attempts (tried ${startPort}-${startPort + maxAttempts - 1})`
3434
- );
3435
- }
3436
- var dashboardCommand = defineCommand({
3437
- name: "dashboard",
3438
- description: "\u{1F4CA} Start the Taskin dashboard with WebSocket server",
3439
- alias: "dash",
3440
- options: [
3441
- {
3442
- flags: "-p, --port <port>",
3443
- description: "Vite dev server port",
3444
- defaultValue: "5173"
3445
- },
3446
- {
3447
- flags: "-w, --ws-port <port>",
3448
- description: "WebSocket server port",
3449
- defaultValue: "3001"
3450
- },
3451
- {
3452
- flags: "-h, --host <host>",
3453
- description: "Host to bind servers",
3454
- defaultValue: "localhost"
3455
- },
3456
- {
3457
- flags: "-b, --browser",
3458
- description: "Open browser automatically"
3459
- },
3460
- {
3461
- flags: "--open",
3462
- description: "Show only open tasks (pending, in-progress, blocked)"
3463
- },
3464
- {
3465
- flags: "--closed",
3466
- description: "Show only closed tasks (done, canceled)"
3792
+ /**
3793
+ * Reads the task id out of an incoming message.
3794
+ *
3795
+ * Ids on the wire are untrusted strings: a branded `TaskId` is only worth
3796
+ * something if the boundary that produces it actually validates. Answers the
3797
+ * client with a clear error instead of letting a ZodError leak out of the
3798
+ * generic catch.
3799
+ */
3800
+ readTaskId(client, message, field = "taskId") {
3801
+ const raw = message.payload?.[field];
3802
+ const parsed = typeof raw === "string" ? TaskIdSchema.safeParse(raw) : void 0;
3803
+ if (!parsed?.success) {
3804
+ this.sendToClient(client.id, {
3805
+ type: "error",
3806
+ payload: { message: `Invalid task id in '${message.type}' request` },
3807
+ requestId: message.requestId
3808
+ });
3809
+ return void 0;
3467
3810
  }
3468
- ],
3469
- handler: async (options) => {
3470
- await startDashboard(options);
3471
- }
3472
- });
3473
- async function startDashboard(options) {
3474
- requireTaskinProject();
3475
- const host = options.host || "localhost";
3476
- if (!isValidHost(host)) {
3477
- error("Security validation failed");
3478
- error(`Invalid host: ${host}. Must be localhost, a valid IPv4 address, or hostname.`);
3479
- process.exit(1);
3480
- }
3481
- if (typeof options.port === "string" && !isValidPort(options.port)) {
3482
- error("Security validation failed");
3483
- error(`Invalid port: ${options.port}. Must be between 1 and 65535.`);
3484
- process.exit(1);
3485
- }
3486
- if (typeof options.wsPort === "string" && !isValidPort(options.wsPort)) {
3487
- error("Security validation failed");
3488
- error(`Invalid WebSocket port: ${options.wsPort}. Must be between 1 and 65535.`);
3489
- process.exit(1);
3811
+ return parsed.data;
3490
3812
  }
3491
- const port = typeof options.port === "string" ? parseInt(options.port, 10) : options.port || 5173;
3492
- const wsPort = typeof options.wsPort === "string" ? parseInt(options.wsPort, 10) : options.wsPort || 3001;
3493
- if (!isValidPort(port)) {
3494
- error("Security validation failed");
3495
- error(`Invalid port: ${port}. Must be between 1 and 65535.`);
3496
- process.exit(1);
3813
+ /**
3814
+ * Handle list request
3815
+ */
3816
+ async handleListRequest(client, message) {
3817
+ const tasks = await this.taskProvider.getAllTasks();
3818
+ const [first] = tasks;
3819
+ if (first) {
3820
+ this.log("[WS Server] Sending", tasks.length, "tasks");
3821
+ this.log("[WS Server] First task assignee:", first.assignee);
3822
+ }
3823
+ this.sendToClient(client.id, {
3824
+ type: "tasks",
3825
+ payload: tasks,
3826
+ requestId: message.requestId
3827
+ });
3497
3828
  }
3498
- if (!isValidPort(wsPort)) {
3499
- error("Security validation failed");
3500
- error(`Invalid WebSocket port: ${wsPort}. Must be between 1 and 65535.`);
3501
- process.exit(1);
3829
+ /**
3830
+ * Handle find request
3831
+ */
3832
+ async handleFindRequest(client, message) {
3833
+ const taskId = this.readTaskId(client, message);
3834
+ if (!taskId)
3835
+ return;
3836
+ const task = await this.taskProvider.findTask(taskId);
3837
+ this.sendToClient(client.id, {
3838
+ type: "task:found",
3839
+ payload: task,
3840
+ requestId: message.requestId
3841
+ });
3502
3842
  }
3503
- printHeader("Starting Taskin Dashboard", "\u{1F4CA}");
3504
- try {
3505
- info("Initializing task provider...");
3506
- let currentDir = process.cwd();
3507
- let tasksDir = path8.join(currentDir, "TASKS");
3508
- try {
3509
- await import("fs").then((fs6) => fs6.promises.access(tasksDir));
3510
- } catch {
3511
- while (currentDir !== path8.dirname(currentDir)) {
3512
- const workspaceFile = path8.join(currentDir, "pnpm-workspace.yaml");
3513
- try {
3514
- await import("fs").then((fs6) => fs6.promises.access(workspaceFile));
3515
- tasksDir = path8.join(currentDir, "TASKS");
3516
- break;
3517
- } catch {
3518
- currentDir = path8.dirname(currentDir);
3519
- }
3520
- }
3843
+ /**
3844
+ * Handle update request
3845
+ */
3846
+ async handleUpdateRequest(client, message) {
3847
+ const taskId = this.readTaskId(client, message, "id");
3848
+ if (!taskId)
3849
+ return;
3850
+ const stored = await this.taskProvider.findTask(taskId);
3851
+ if (!stored) {
3852
+ this.sendToClient(client.id, {
3853
+ type: "error",
3854
+ payload: { message: `Task ${taskId} not found` },
3855
+ requestId: message.requestId
3856
+ });
3857
+ return;
3521
3858
  }
3522
- info(`Using tasks directory: ${tasksDir}`);
3523
- const monorepoRoot = path8.dirname(tasksDir);
3524
- const { provider } = await resolveTaskProvider({ cwd: monorepoRoot });
3525
- const manager = new TaskManager(provider);
3526
- info(`Starting WebSocket server on ${host}:${wsPort}...`);
3527
- const wsServer = new TaskWebSocketServer({
3528
- taskManager: manager,
3529
- taskProvider: provider,
3530
- options: {
3531
- port: wsPort,
3532
- host
3533
- }
3859
+ const outcome = applyTaskUpdate(stored, message.payload);
3860
+ if (!outcome.ok) {
3861
+ this.sendToClient(client.id, {
3862
+ type: "error",
3863
+ payload: { message: outcome.message },
3864
+ requestId: message.requestId
3865
+ });
3866
+ return;
3867
+ }
3868
+ await this.taskProvider.updateTask(outcome.task);
3869
+ this.broadcast({
3870
+ type: "task:updated",
3871
+ payload: outcome.task
3534
3872
  });
3535
- await wsServer.start();
3536
- success(`\u2713 WebSocket server running on ws://${host}:${wsPort}`);
3537
- info(`Starting dashboard server on http://${host}:${port}...`);
3538
- const isDev = __dirname2.includes("/src/");
3539
- const dashboardDist = isDev ? path8.join(__dirname2, "..", "..", "dashboard-dist") : path8.join(__dirname2, "..", "dashboard-dist");
3540
- const app = express();
3541
- app.disable("x-powered-by");
3542
- app.use((_req, res, next) => {
3543
- res.setHeader("X-Frame-Options", "DENY");
3544
- res.setHeader("X-Content-Type-Options", "nosniff");
3545
- res.setHeader("X-XSS-Protection", "1; mode=block");
3546
- res.setHeader(
3547
- "Content-Security-Policy",
3548
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:;"
3549
- );
3550
- next();
3873
+ }
3874
+ /**
3875
+ * Handle start task request
3876
+ */
3877
+ async handleStartRequest(client, message) {
3878
+ const taskId = this.readTaskId(client, message);
3879
+ if (!taskId)
3880
+ return;
3881
+ const task = await this.taskManager.startTask(taskId);
3882
+ this.broadcast({
3883
+ type: "task:updated",
3884
+ payload: task
3551
3885
  });
3552
- app.use((req, res, next) => {
3553
- if (req.path === "/" || req.path === "/index.html") {
3554
- import("fs").then((fs6) => fs6.promises.readFile(path8.join(dashboardDist, "index.html"), "utf-8")).then((html) => {
3555
- const safeHost = escapeHtml(host);
3556
- const safeWsPort = escapeHtml(String(wsPort));
3557
- const injectedHtml = html.replace(
3558
- "</head>",
3559
- `<script>window.VITE_WS_URL = 'ws://${safeHost}:${safeWsPort}';</script></head>`
3560
- );
3561
- res.send(injectedHtml);
3562
- }).catch((err) => {
3563
- console.error("Failed to read index.html:", err);
3564
- res.status(500).send("Internal Server Error");
3565
- });
3566
- } else {
3567
- next();
3568
- }
3886
+ }
3887
+ /**
3888
+ * Handle finish task request
3889
+ */
3890
+ async handleFinishRequest(client, message) {
3891
+ const taskId = this.readTaskId(client, message);
3892
+ if (!taskId)
3893
+ return;
3894
+ const task = await this.taskManager.finishTask(taskId);
3895
+ this.broadcast({
3896
+ type: "task:updated",
3897
+ payload: task
3569
3898
  });
3570
- app.use(
3571
- express.static(dashboardDist, {
3572
- dotfiles: "deny",
3573
- // Deny access to dotfiles
3574
- index: false,
3575
- // Don't serve index.html here (handled above)
3576
- redirect: false
3577
- // Don't redirect to trailing slash
3578
- })
3579
- );
3580
- app.use((_req, res) => {
3581
- res.status(404).send("Not Found");
3899
+ }
3900
+ /**
3901
+ * Handle pause task request
3902
+ */
3903
+ async handlePauseRequest(client, message) {
3904
+ const taskId = this.readTaskId(client, message);
3905
+ if (!taskId)
3906
+ return;
3907
+ const task = await this.taskManager.pauseTask(taskId);
3908
+ this.broadcast({
3909
+ type: "task:updated",
3910
+ payload: task
3582
3911
  });
3583
- const { server: httpServer, port: actualPort } = await startHttpServer(app, port, host);
3584
- if (actualPort !== port) {
3585
- warning(`Port ${port} was in use. Dashboard started on port ${actualPort}.`);
3586
- }
3587
- success(`\u2713 Dashboard available at http://${host}:${actualPort}`);
3588
- const filterParams = new URLSearchParams();
3589
- if (options.open) {
3590
- filterParams.set("filter", "open");
3591
- } else if (options.closed) {
3592
- filterParams.set("filter", "closed");
3593
- }
3594
- const filterQuery = filterParams.toString() ? `?${filterParams.toString()}` : "";
3595
- if (options.browser) {
3596
- const url = `http://${host}:${actualPort}${filterQuery}`;
3597
- await import("child_process").then((cp) => {
3598
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3599
- cp.exec(`${cmd} ${url}`);
3912
+ }
3913
+ /**
3914
+ * Start heartbeat to check client connections
3915
+ */
3916
+ startHeartbeat() {
3917
+ this.heartbeatInterval = setInterval(() => {
3918
+ this.clients.forEach((client) => {
3919
+ if (!client.isAlive) {
3920
+ this.log(`Client ${client.id} timeout, terminating`);
3921
+ client.ws.terminate();
3922
+ this.clients.delete(client.id);
3923
+ return;
3924
+ }
3925
+ client.isAlive = false;
3926
+ client.ws.ping();
3600
3927
  });
3928
+ }, this.options.heartbeatInterval);
3929
+ }
3930
+ /**
3931
+ * Stop heartbeat
3932
+ */
3933
+ stopHeartbeat() {
3934
+ if (this.heartbeatInterval) {
3935
+ clearInterval(this.heartbeatInterval);
3936
+ this.heartbeatInterval = null;
3601
3937
  }
3602
- info("");
3603
- info(chalk4.bold("Dashboard Controls:"));
3604
- info(` \u2022 Dashboard: ${chalk4.cyan(`http://${host}:${actualPort}${filterQuery}`)}`);
3605
- info(` \u2022 WebSocket: ${chalk4.cyan(`ws://${host}:${wsPort}`)}`);
3606
- if (options.open) {
3607
- info(` \u2022 Filter: ${chalk4.yellow("Open tasks only")}`);
3608
- } else if (options.closed) {
3609
- info(` \u2022 Filter: ${chalk4.yellow("Closed tasks only")}`);
3610
- }
3611
- info(` \u2022 Press ${chalk4.bold("Ctrl+C")} to stop both servers`);
3612
- info("");
3613
- const cleanup = async () => {
3614
- info("\nShutting down servers...");
3615
- await new Promise((resolve) => {
3616
- httpServer.close(() => resolve());
3617
- });
3618
- await wsServer.stop();
3619
- success("\u2713 Servers stopped");
3620
- process.exit(0);
3621
- };
3622
- process.on("SIGINT", cleanup);
3623
- process.on("SIGTERM", cleanup);
3624
- } catch (err) {
3625
- error("Failed to start dashboard");
3626
- if (err instanceof Error) {
3627
- error(err.message);
3938
+ }
3939
+ /**
3940
+ * Debug logging
3941
+ */
3942
+ log(...args) {
3943
+ if (this.options.debug) {
3944
+ console.log("[TaskWebSocketServer]", ...args);
3628
3945
  }
3629
- process.exit(1);
3630
3946
  }
3631
- }
3947
+ };
3632
3948
 
3633
- // src/commands/export.ts
3949
+ // node_modules/@opentask/taskin-task-server-ws/dist/task-server-ws.mock.js
3950
+ init_esm_shims();
3951
+ import { parseTaskId } from "@opentask/taskin-types";
3952
+
3953
+ // node_modules/@opentask/taskin-task-server-ws/dist/task-server-ws.types.js
3634
3954
  init_esm_shims();
3955
+
3956
+ // src/commands/dashboard.ts
3635
3957
  init_src2();
3958
+ import chalk4 from "chalk";
3959
+ import express from "express";
3960
+ import { createServer } from "http";
3961
+ import path8 from "path";
3962
+ import { fileURLToPath as fileURLToPath2 } from "url";
3636
3963
 
3637
- // ../git-utils/src/index.ts
3964
+ // src/lib/provider-factory/index.ts
3638
3965
  init_esm_shims();
3639
3966
 
3640
- // ../git-utils/src/git.ts
3967
+ // src/lib/provider-factory/provider-factory.ts
3641
3968
  init_esm_shims();
3642
- import { execSync as execSync2 } from "child_process";
3643
- function executeGit(command) {
3969
+ import path7 from "path";
3970
+
3971
+ // src/lib/notification/env-resolver.ts
3972
+ init_esm_shims();
3973
+ import { readFileSync as readFileSync2 } from "fs";
3974
+ import { join as join2 } from "path";
3975
+ var ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
3976
+ function loadDotEnv(dir) {
3644
3977
  try {
3645
- return execSync2(`git ${command}`, {
3646
- encoding: "utf8",
3647
- stdio: ["pipe", "pipe", "pipe"]
3648
- }).trim();
3978
+ const envPath = join2(dir ?? process.cwd(), ".env");
3979
+ const content = readFileSync2(envPath, "utf-8");
3980
+ for (const line of content.split("\n")) {
3981
+ const trimmed = line.trim();
3982
+ if (!trimmed || trimmed.startsWith("#")) continue;
3983
+ const eqIndex = trimmed.indexOf("=");
3984
+ if (eqIndex === -1) continue;
3985
+ const key = trimmed.slice(0, eqIndex).trim();
3986
+ let value = trimmed.slice(eqIndex + 1).trim();
3987
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3988
+ value = value.slice(1, -1);
3989
+ }
3990
+ if (!process.env[key]) {
3991
+ process.env[key] = value;
3992
+ }
3993
+ }
3649
3994
  } catch {
3650
- return "";
3651
3995
  }
3652
3996
  }
3653
- function isGitRepository() {
3654
- return !!executeGit("rev-parse --is-inside-work-tree");
3655
- }
3656
- function getCurrentBranch() {
3657
- return executeGit("branch --show-current");
3997
+ function resolveEnvVars(value) {
3998
+ return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
3999
+ return process.env[varName] ?? "";
4000
+ });
3658
4001
  }
3659
- function createBranch(branchName, baseBranch) {
3660
- const base = baseBranch || getCurrentBranch();
3661
- executeGit(`checkout -b ${branchName} ${base}`);
4002
+
4003
+ // src/lib/provider-registry/index.ts
4004
+ init_esm_shims();
4005
+
4006
+ // src/lib/provider-registry/provider-registry.ts
4007
+ init_esm_shims();
4008
+ var AVAILABLE_PROVIDERS = [
4009
+ {
4010
+ id: "fs",
4011
+ name: "\u{1F4C1} File System",
4012
+ description: "Store tasks as Markdown files in a local TASKS/ directory",
4013
+ packageName: "@opentask/taskin-file-system-provider",
4014
+ configSchema: {
4015
+ required: ["tasksDir"],
4016
+ properties: {
4017
+ tasksDir: {
4018
+ type: "string",
4019
+ description: "Directory to store task files"
4020
+ },
4021
+ metadataStyle: {
4022
+ type: "string",
4023
+ description: "Marking of the metadata block for new files (list | hard-break | plain)"
4024
+ }
4025
+ }
4026
+ },
4027
+ status: "stable"
4028
+ },
4029
+ {
4030
+ id: "redmine",
4031
+ name: "\u{1F534} Redmine",
4032
+ description: "Sync tasks with Redmine issues via REST API",
4033
+ packageName: "@opentask/taskin-redmine-provider",
4034
+ configSchema: {
4035
+ required: ["apiUrl", "apiKey", "projectId"],
4036
+ properties: {
4037
+ apiUrl: {
4038
+ type: "string",
4039
+ description: "Redmine server URL (e.g., https://redmine.example.com)"
4040
+ },
4041
+ apiKey: {
4042
+ type: "string",
4043
+ description: "Your Redmine API key",
4044
+ secret: true
4045
+ },
4046
+ projectId: {
4047
+ type: "string",
4048
+ description: "Project identifier or ID"
4049
+ }
4050
+ }
4051
+ },
4052
+ status: "coming-soon"
4053
+ },
4054
+ {
4055
+ id: "jira",
4056
+ name: "\u{1F535} Jira",
4057
+ description: "Sync tasks with Jira issues via REST API",
4058
+ packageName: "@opentask/taskin-jira-provider",
4059
+ configSchema: {
4060
+ required: ["apiUrl", "email", "apiToken", "projectKey"],
4061
+ properties: {
4062
+ apiUrl: {
4063
+ type: "string",
4064
+ description: "Jira server URL (e.g., https://company.atlassian.net)"
4065
+ },
4066
+ email: {
4067
+ type: "string",
4068
+ description: "Your Atlassian account email"
4069
+ },
4070
+ apiToken: {
4071
+ type: "string",
4072
+ description: "Your Jira API token",
4073
+ secret: true
4074
+ },
4075
+ projectKey: {
4076
+ type: "string",
4077
+ description: "Project key (e.g., PROJ)"
4078
+ }
4079
+ }
4080
+ },
4081
+ status: "coming-soon"
4082
+ },
4083
+ {
4084
+ id: "github",
4085
+ name: "\u{1F419} GitHub Issues",
4086
+ description: "Sync tasks with GitHub Issues",
4087
+ packageName: "@opentask/taskin-github-provider",
4088
+ configSchema: {
4089
+ required: ["owner", "repo", "token"],
4090
+ properties: {
4091
+ owner: {
4092
+ type: "string",
4093
+ description: "Repository owner (username or organization)"
4094
+ },
4095
+ repo: {
4096
+ type: "string",
4097
+ description: "Repository name"
4098
+ },
4099
+ token: {
4100
+ type: "string",
4101
+ description: "GitHub Personal Access Token",
4102
+ secret: true
4103
+ }
4104
+ }
4105
+ },
4106
+ status: "coming-soon"
4107
+ }
4108
+ ];
4109
+ function getProviderById(id) {
4110
+ return AVAILABLE_PROVIDERS.find((p) => p.id === id);
4111
+ }
4112
+ function getAllProviders() {
4113
+ return AVAILABLE_PROVIDERS;
3662
4114
  }
3663
4115
 
3664
- // ../git-utils/src/git.types.ts
4116
+ // src/lib/provider-registry/provider-registry.types.ts
3665
4117
  init_esm_shims();
3666
4118
 
3667
- // ../git-utils/src/git-analyzer.ts
3668
- init_esm_shims();
3669
- import { exec } from "child_process";
3670
- import { promisify } from "util";
3671
- var execAsync = promisify(exec);
3672
- function isNodeError(error2) {
3673
- return typeof error2 === "object" && error2 !== null && "code" in error2;
3674
- }
3675
- async function executeGit2(command, cwd) {
3676
- try {
3677
- const { stdout } = await execAsync(`git ${command}`, {
3678
- cwd: cwd || process.cwd(),
3679
- encoding: "utf8",
3680
- maxBuffer: 10 * 1024 * 1024,
3681
- // 10MB buffer for large repos
3682
- timeout: 3e4
3683
- // 30 second timeout
3684
- });
3685
- return stdout.trim();
3686
- } catch (error2) {
3687
- if (isNodeError(error2)) {
3688
- if (error2.code === "ENOENT") {
3689
- throw new Error("Git is not installed or not in PATH");
3690
- }
3691
- }
3692
- return "";
4119
+ // src/lib/provider-factory/provider-factory.ts
4120
+ function expandProviderConfig(config) {
4121
+ const expanded = {};
4122
+ for (const [key, value] of Object.entries(config)) {
4123
+ expanded[key] = typeof value === "string" ? resolveEnvVars(value) : value;
3693
4124
  }
4125
+ return expanded;
3694
4126
  }
3695
- var GitAnalyzer = class {
3696
- constructor(repositoryPath) {
3697
- this.repositoryPath = repositoryPath;
3698
- }
3699
- async isValidRepository() {
3700
- try {
3701
- const result = await executeGit2("rev-parse --is-inside-work-tree", this.repositoryPath);
3702
- return result === "true";
3703
- } catch {
3704
- return false;
3705
- }
3706
- }
3707
- async getRepositoryRoot() {
3708
- const result = await executeGit2("rev-parse --show-toplevel", this.repositoryPath);
3709
- if (!result) {
3710
- throw new Error("Not a git repository");
3711
- }
3712
- return result;
3713
- }
3714
- async getCommits(options = {}) {
3715
- const args = ["log"];
3716
- args.push(
3717
- "--pretty='format:%H|%an|%aI|%s%x00%b'",
3718
- "--numstat"
3719
- // Get file stats
4127
+ var buildFileSystemProvider = async ({ projectRoot, providerConfig, tasksDirOverride }) => {
4128
+ const { FileSystemTaskProvider: FileSystemTaskProvider2, isMetadataStyleId: isMetadataStyleId2, UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src3(), src_exports));
4129
+ const configuredTasksDir = typeof providerConfig.tasksDir === "string" ? providerConfig.tasksDir : "TASKS";
4130
+ const tasksDir = path7.resolve(projectRoot, tasksDirOverride ?? configuredTasksDir);
4131
+ const userRegistry = new UserRegistry2({ taskinDir: path7.join(projectRoot, ".taskin") });
4132
+ const metadataStyle = isMetadataStyleId2(providerConfig.metadataStyle) ? providerConfig.metadataStyle : void 0;
4133
+ const convertMetadataStyleTo = isMetadataStyleId2(providerConfig.convertMetadataStyleTo) ? providerConfig.convertMetadataStyleTo : void 0;
4134
+ const provider = new FileSystemTaskProvider2(tasksDir, userRegistry, void 0, void 0, {
4135
+ ...metadataStyle !== void 0 && { metadataStyle },
4136
+ ...convertMetadataStyleTo !== void 0 && { convertMetadataStyleTo }
4137
+ });
4138
+ return { provider, userRegistry };
4139
+ };
4140
+ var PROVIDER_BUILDERS = {
4141
+ fs: buildFileSystemProvider
4142
+ };
4143
+ function unknownProviderError(providerType, builders) {
4144
+ const known = getProviderById(providerType);
4145
+ if (known) {
4146
+ return new Error(
4147
+ `Provider "${providerType}" (${known.name}) is configured in .taskin.json but has no implementation yet (status: ${known.status}).
4148
+ Install/await ${known.packageName}, or change provider.type.`
3720
4149
  );
3721
- if (options.since) {
3722
- args.push(`--since="${options.since}"`);
3723
- }
3724
- if (options.until) {
3725
- args.push(`--until=${options.until}`);
3726
- }
3727
- if (options.author) {
3728
- args.push(`--author='${options.author}'`);
3729
- }
3730
- if (options.maxCount) {
3731
- args.push(`-n ${options.maxCount}`);
3732
- }
3733
- if (!options.includeMerges) {
3734
- args.push("--no-merges");
3735
- }
3736
- if (options.filePath) {
3737
- args.push("--", options.filePath);
3738
- }
3739
- const command = args.join(" ");
3740
- const output = await executeGit2(command, this.repositoryPath);
3741
- if (!output) {
3742
- return [];
3743
- }
3744
- return this.parseCommits(output);
3745
- }
3746
- parseCommits(output) {
3747
- const commits = [];
3748
- const lines = output.split("\n");
3749
- let i = 0;
3750
- while (i < lines.length) {
3751
- const line = lines[i];
3752
- if (line === void 0) break;
3753
- if (!line.trim()) {
3754
- i++;
3755
- continue;
3756
- }
3757
- if (line.includes("|")) {
3758
- const [hash, author, date, messageWithBody] = line.split("|");
3759
- if (hash === void 0 || author === void 0 || date === void 0 || messageWithBody === void 0) {
3760
- i++;
3761
- continue;
3762
- }
3763
- if (!/^[0-9a-f]{6,40}$/i.test(hash)) {
3764
- i++;
3765
- continue;
3766
- }
3767
- const nullByteIndex = messageWithBody.indexOf("\0");
3768
- const subject = nullByteIndex !== -1 ? messageWithBody.substring(0, nullByteIndex) : messageWithBody;
3769
- const bodyLines = [];
3770
- if (nullByteIndex !== -1) {
3771
- bodyLines.push(messageWithBody.substring(nullByteIndex + 1));
3772
- }
3773
- i++;
3774
- while (i < lines.length) {
3775
- const bodyLine = lines[i];
3776
- if (bodyLine === void 0 || bodyLine.includes("|") || bodyLine.includes(" ")) break;
3777
- if (bodyLine.trim()) {
3778
- bodyLines.push(bodyLine);
3779
- }
3780
- i++;
3781
- }
3782
- const body = bodyLines.join("\n");
3783
- const fullMessage = body ? `${subject}
3784
-
3785
- ${body}` : subject;
3786
- let filesChanged = 0;
3787
- let linesAdded = 0;
3788
- let linesRemoved = 0;
3789
- while (i < lines.length) {
3790
- const rawStatLine = lines[i];
3791
- if (rawStatLine === void 0 || rawStatLine.includes("|")) break;
3792
- const statLine = rawStatLine.trim();
3793
- if (!statLine) {
3794
- i++;
3795
- continue;
3796
- }
3797
- const [added, removed] = statLine.split(" ");
3798
- if (added !== "-" && removed !== "-") {
3799
- linesAdded += parseInt(added || "0", 10);
3800
- linesRemoved += parseInt(removed || "0", 10);
3801
- filesChanged++;
3802
- }
3803
- i++;
3804
- }
3805
- commits.push({
3806
- hash,
3807
- author,
3808
- date,
3809
- message: subject,
3810
- filesChanged,
3811
- linesAdded,
3812
- linesRemoved,
3813
- coAuthors: this.extractCoAuthors(fullMessage)
3814
- });
3815
- } else {
3816
- i++;
3817
- }
3818
- }
3819
- return commits;
3820
- }
3821
- extractCoAuthors(message) {
3822
- const coAuthorRegex = /Co-authored-by:\s*(.+?)\s*<(.+?)>/gi;
3823
- const matches = Array.from(message.matchAll(coAuthorRegex));
3824
- const names = matches.flatMap((match) => match[1]?.trim() ?? []);
3825
- return names.length > 0 ? names : void 0;
3826
- }
3827
- async getDiff(from = "HEAD", to = "") {
3828
- const args = ["diff", "--numstat"];
3829
- if (to) {
3830
- args.push(`${from}..${to}`);
3831
- } else {
3832
- args.push(from);
3833
- }
3834
- const output = await executeGit2(args.join(" "), this.repositoryPath);
3835
- return this.parseDiff(output);
3836
- }
3837
- parseDiff(output) {
3838
- const files = [];
3839
- let totalLinesAdded = 0;
3840
- let totalLinesRemoved = 0;
3841
- if (!output) {
3842
- return {
3843
- files,
3844
- totalLinesAdded,
3845
- totalLinesRemoved,
3846
- netChange: 0
3847
- };
3848
- }
3849
- const lines = output.split("\n").filter((l) => l.trim());
3850
- for (const line of lines) {
3851
- const [added, removed, path12] = line.split(" ");
3852
- if (added === void 0 || removed === void 0 || path12 === void 0) continue;
3853
- if (added === "-" || removed === "-") {
3854
- continue;
3855
- }
3856
- const linesAdded = parseInt(added, 10);
3857
- const linesRemoved = parseInt(removed, 10);
3858
- totalLinesAdded += linesAdded;
3859
- totalLinesRemoved += linesRemoved;
3860
- files.push({
3861
- path: path12,
3862
- linesAdded,
3863
- linesRemoved,
3864
- changeType: "modified"
3865
- // Simplified - could be enhanced with --name-status
3866
- });
3867
- }
3868
- return {
3869
- files,
3870
- totalLinesAdded,
3871
- totalLinesRemoved,
3872
- netChange: totalLinesAdded - totalLinesRemoved
3873
- };
3874
- }
3875
- async getFileDiff(filePath, from = "HEAD", to = "") {
3876
- const args = ["diff", "--numstat"];
3877
- if (to) {
3878
- args.push(`${from}..${to}`);
3879
- } else {
3880
- args.push(from);
3881
- }
3882
- args.push("--", filePath);
3883
- const output = await executeGit2(args.join(" "), this.repositoryPath);
3884
- if (!output) {
3885
- return null;
3886
- }
3887
- const [added, removed, path12] = output.split(" ");
3888
- if (added === void 0 || removed === void 0 || path12 === void 0) {
3889
- return null;
3890
- }
3891
- if (added === "-" || removed === "-") {
3892
- return null;
3893
- }
3894
- return {
3895
- path: path12,
3896
- linesAdded: parseInt(added, 10),
3897
- linesRemoved: parseInt(removed, 10),
3898
- changeType: "modified"
3899
- };
3900
- }
3901
- async getBlame(filePath) {
3902
- const args = ["blame", "--line-porcelain", filePath];
3903
- const output = await executeGit2(args.join(" "), this.repositoryPath);
3904
- if (!output) {
3905
- return [];
3906
- }
3907
- return this.parseBlame(output);
3908
- }
3909
- parseBlame(output) {
3910
- const lines = output.split("\n");
3911
- const blameInfo = [];
3912
- let currentHash = "";
3913
- let currentAuthor = "";
3914
- let currentDate = "";
3915
- let lineNumber = 0;
3916
- for (const line of lines) {
3917
- if (line.match(/^[0-9a-f]{6,40}/i)) {
3918
- const [hash, , finalLine] = line.split(" ");
3919
- currentHash = hash ?? "";
3920
- lineNumber = parseInt(finalLine ?? "", 10);
3921
- } else if (line.startsWith("author ")) {
3922
- currentAuthor = line.substring(7);
3923
- } else if (line.startsWith("author-time ")) {
3924
- const timestamp = parseInt(line.substring(12), 10);
3925
- currentDate = new Date(timestamp * 1e3).toISOString();
3926
- } else if (line.startsWith(" ")) {
3927
- const content = line.substring(1);
3928
- blameInfo.push({
3929
- lineNumber,
3930
- commitHash: currentHash,
3931
- author: currentAuthor,
3932
- date: new Date(currentDate),
3933
- content
3934
- });
3935
- }
3936
- }
3937
- return blameInfo;
3938
- }
3939
- async getAuthors(options = {}) {
3940
- const args = ["shortlog", "-sne"];
3941
- if (options.since) {
3942
- args.push(`--since=${options.since}`);
3943
- }
3944
- if (options.until) {
3945
- args.push(`--until=${options.until}`);
3946
- }
3947
- if (!options.includeMerges) {
3948
- args.push("--no-merges");
3949
- }
3950
- if (options.filePath) {
3951
- args.push("--", options.filePath);
3952
- }
3953
- const command = args.join(" ");
3954
- const output = await executeGit2(command, this.repositoryPath);
3955
- if (!output) {
3956
- return [];
3957
- }
3958
- return this.parseAuthors(output);
3959
- }
3960
- parseAuthors(output) {
3961
- const lines = output.split("\n").filter((l) => l.trim());
3962
- const authors = [];
3963
- for (const line of lines) {
3964
- const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+?)>/);
3965
- if (match) {
3966
- const [, commits, name, email] = match;
3967
- if (commits === void 0 || name === void 0 || email === void 0) continue;
3968
- authors.push({
3969
- name,
3970
- email,
3971
- commits: parseInt(commits, 10)
3972
- });
3973
- }
3974
- }
3975
- return authors;
3976
4150
  }
3977
- async getFileHistory(filePath, options = {}) {
3978
- return this.getCommits({
3979
- ...options,
3980
- filePath
3981
- });
4151
+ const buildable = Object.keys(builders).join(", ");
4152
+ const listed = getAllProviders().map((provider) => provider.id).join(", ");
4153
+ return new Error(
4154
+ `Unknown provider.type "${providerType}" in .taskin.json.
4155
+ Usable now: ${buildable}. Known ids: ${listed}.`
4156
+ );
4157
+ }
4158
+ async function resolveTaskProvider(options = {}, builders = PROVIDER_BUILDERS) {
4159
+ const projectRoot = path7.resolve(options.cwd ?? process.cwd());
4160
+ loadDotEnv(projectRoot);
4161
+ const config = new ConfigManager(projectRoot).loadConfig();
4162
+ const providerType = config.provider.type;
4163
+ const build = builders[providerType];
4164
+ if (!build) {
4165
+ throw unknownProviderError(providerType, builders);
3982
4166
  }
3983
- };
4167
+ const context = {
4168
+ projectRoot,
4169
+ providerConfig: { ...expandProviderConfig(config.provider.config), ...options.configOverrides },
4170
+ ...options.tasksDir !== void 0 && { tasksDirOverride: options.tasksDir }
4171
+ };
4172
+ const { provider, userRegistry } = await build(context);
4173
+ await userRegistry.load();
4174
+ return { provider, userRegistry, projectRoot, providerType };
4175
+ }
3984
4176
 
3985
- // ../git-utils/src/git-analyzer.types.ts
4177
+ // src/lib/provider-factory/provider-factory.types.ts
3986
4178
  init_esm_shims();
3987
4179
 
3988
- // ../git-utils/src/git-service.ts
3989
- init_esm_shims();
3990
- import { execSync as execSync3 } from "child_process";
3991
- var GitService = class {
3992
- constructor(cwd = process.cwd()) {
3993
- this.cwd = cwd;
3994
- }
3995
- async addFiles(pattern) {
3996
- try {
3997
- execSync3(`git add ${pattern}`, {
3998
- cwd: this.cwd,
3999
- stdio: "ignore"
4000
- });
4001
- return true;
4002
- } catch {
4003
- return false;
4004
- }
4005
- }
4006
- async commit(message) {
4180
+ // src/commands/dashboard.ts
4181
+ var __filename2 = fileURLToPath2(import.meta.url);
4182
+ var __dirname2 = path8.dirname(__filename2);
4183
+ async function startHttpServer(app, startPort, host, maxAttempts = 10) {
4184
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
4185
+ const tryPort = startPort + attempt;
4007
4186
  try {
4008
- execSync3(`git commit -m "${message}"`, {
4009
- cwd: this.cwd,
4010
- stdio: "ignore"
4187
+ const server = createServer(app);
4188
+ await new Promise((resolve, reject) => {
4189
+ server.once("error", reject);
4190
+ server.listen(tryPort, host, () => resolve());
4011
4191
  });
4012
- return true;
4013
- } catch {
4014
- return false;
4015
- }
4016
- }
4017
- async addAndCommit(pattern, message) {
4018
- const added = await this.addFiles(pattern);
4019
- if (!added) return false;
4020
- return this.commit(message);
4021
- }
4022
- async commitTaskStatusChange(taskId, status) {
4023
- const pattern = `TASKS/task-${taskId}-*.md`;
4024
- const message = `docs(TASKS): task-${taskId} - atualiza status para ${status} [skip-ci]`;
4025
- return this.addAndCommit(pattern, message);
4026
- }
4027
- async commitTaskStatusChangeOnBranch(taskId, status, defaultBranch) {
4028
- if (!defaultBranch) {
4029
- return this.commitTaskStatusChange(taskId, status);
4030
- }
4031
- try {
4032
- const currentBranch = execSync3("git rev-parse --abbrev-ref HEAD", {
4033
- cwd: this.cwd,
4034
- encoding: "utf8",
4035
- stdio: "pipe"
4036
- }).trim();
4037
- if (currentBranch === defaultBranch) {
4038
- return this.commitTaskStatusChange(taskId, status);
4039
- }
4040
- const taskPattern = `TASKS/task-${taskId}-*.md`;
4041
- let taskFileContent = null;
4042
- let taskFilePath = null;
4043
- try {
4044
- const files = execSync3(`git ls-files -m ${taskPattern}`, {
4045
- cwd: this.cwd,
4046
- encoding: "utf8",
4047
- stdio: "pipe"
4048
- }).trim();
4049
- if (files) {
4050
- taskFilePath = files.split("\n")[0] ?? null;
4051
- const { readFileSync: readFileSync4 } = await import("fs");
4052
- taskFileContent = readFileSync4(`${this.cwd}/${taskFilePath}`, "utf-8");
4053
- }
4054
- } catch {
4192
+ return { server, port: tryPort };
4193
+ } catch (err) {
4194
+ const nodeErr = err;
4195
+ if (nodeErr.code !== "EADDRINUSE") {
4196
+ throw err;
4055
4197
  }
4056
- const hasChanges = await this.hasUncommittedChanges();
4057
- let stashed = false;
4058
- try {
4059
- if (hasChanges) {
4060
- execSync3('git stash push -u -m "taskin-temp-stash"', {
4061
- cwd: this.cwd,
4062
- stdio: "ignore"
4063
- });
4064
- stashed = true;
4065
- }
4066
- execSync3(`git checkout ${defaultBranch}`, {
4067
- cwd: this.cwd,
4068
- stdio: "ignore"
4069
- });
4070
- let committed = false;
4071
- if (taskFileContent && taskFilePath) {
4072
- const { writeFileSync: writeFileSync3 } = await import("fs");
4073
- writeFileSync3(`${this.cwd}/${taskFilePath}`, taskFileContent, "utf-8");
4074
- const message = `docs(TASKS): task-${taskId} - atualiza status para ${status} [skip-ci]`;
4075
- committed = await this.addAndCommit(taskPattern, message);
4076
- } else {
4077
- const message = `docs(TASKS): task-${taskId} - atualiza status para ${status} [skip-ci]`;
4078
- committed = await this.addAndCommit(taskPattern, message);
4079
- }
4080
- execSync3(`git checkout ${currentBranch}`, {
4081
- cwd: this.cwd,
4082
- stdio: "ignore"
4083
- });
4084
- if (stashed) {
4085
- execSync3("git stash pop", {
4086
- cwd: this.cwd,
4087
- stdio: "ignore"
4088
- });
4089
- }
4090
- return committed;
4091
- } catch {
4092
- try {
4093
- execSync3(`git checkout ${currentBranch}`, {
4094
- cwd: this.cwd,
4095
- stdio: "ignore"
4096
- });
4097
- if (stashed) {
4098
- execSync3("git stash pop", {
4099
- cwd: this.cwd,
4100
- stdio: "ignore"
4101
- });
4102
- }
4103
- } catch {
4104
- }
4105
- return false;
4198
+ if (attempt < maxAttempts - 1) {
4199
+ info(`Port ${tryPort} is in use, trying port ${tryPort + 1}...`);
4106
4200
  }
4107
- } catch {
4108
- return false;
4109
4201
  }
4110
4202
  }
4111
- async hasUncommittedChanges() {
4112
- try {
4113
- const output = execSync3("git status --porcelain", {
4114
- cwd: this.cwd,
4115
- encoding: "utf8",
4116
- stdio: "pipe"
4117
- });
4118
- return output.trim().length > 0;
4119
- } catch {
4120
- return false;
4121
- }
4203
+ throw new Error(
4204
+ `Could not find an available port after ${maxAttempts} attempts (tried ${startPort}-${startPort + maxAttempts - 1})`
4205
+ );
4206
+ }
4207
+ var dashboardCommand = defineCommand({
4208
+ name: "dashboard",
4209
+ description: "\u{1F4CA} Start the Taskin dashboard with WebSocket server",
4210
+ alias: "dash",
4211
+ options: [
4212
+ {
4213
+ flags: "-p, --port <port>",
4214
+ description: "Vite dev server port",
4215
+ defaultValue: "5173"
4216
+ },
4217
+ {
4218
+ flags: "-w, --ws-port <port>",
4219
+ description: "WebSocket server port",
4220
+ defaultValue: "3001"
4221
+ },
4222
+ {
4223
+ flags: "-h, --host <host>",
4224
+ description: "Host to bind servers",
4225
+ defaultValue: "localhost"
4226
+ },
4227
+ {
4228
+ flags: "-b, --browser",
4229
+ description: "Open browser automatically"
4230
+ },
4231
+ {
4232
+ flags: "--open",
4233
+ description: "Show only open tasks (pending, in-progress, blocked)"
4234
+ },
4235
+ {
4236
+ flags: "--closed",
4237
+ description: "Show only closed tasks (done, canceled)"
4238
+ }
4239
+ ],
4240
+ handler: async (options) => {
4241
+ await startDashboard(options);
4242
+ }
4243
+ });
4244
+ async function startDashboard(options) {
4245
+ requireTaskinProject();
4246
+ const host = options.host || "localhost";
4247
+ if (!isValidHost(host)) {
4248
+ error("Security validation failed");
4249
+ error(`Invalid host: ${host}. Must be localhost, a valid IPv4 address, or hostname.`);
4250
+ process.exit(1);
4122
4251
  }
4123
- async getCurrentBranch() {
4124
- try {
4125
- return execSync3("git branch --show-current", {
4126
- cwd: this.cwd,
4127
- encoding: "utf8",
4128
- stdio: "pipe"
4129
- }).trim();
4130
- } catch {
4131
- return "";
4132
- }
4252
+ if (typeof options.port === "string" && !isValidPort(options.port)) {
4253
+ error("Security validation failed");
4254
+ error(`Invalid port: ${options.port}. Must be between 1 and 65535.`);
4255
+ process.exit(1);
4133
4256
  }
4134
- async isGitRepository() {
4135
- return Promise.resolve(isGitRepository());
4257
+ if (typeof options.wsPort === "string" && !isValidPort(options.wsPort)) {
4258
+ error("Security validation failed");
4259
+ error(`Invalid WebSocket port: ${options.wsPort}. Must be between 1 and 65535.`);
4260
+ process.exit(1);
4136
4261
  }
4137
- async createBranch(branchName, baseBranch) {
4138
- try {
4139
- createBranch(branchName, baseBranch);
4140
- return true;
4141
- } catch {
4142
- return false;
4143
- }
4262
+ const port = typeof options.port === "string" ? parseInt(options.port, 10) : options.port || 5173;
4263
+ const wsPort = typeof options.wsPort === "string" ? parseInt(options.wsPort, 10) : options.wsPort || 3001;
4264
+ if (!isValidPort(port)) {
4265
+ error("Security validation failed");
4266
+ error(`Invalid port: ${port}. Must be between 1 and 65535.`);
4267
+ process.exit(1);
4144
4268
  }
4145
- async checkoutBranch(branchName) {
4146
- try {
4147
- execSync3(`git checkout ${branchName}`, {
4148
- cwd: this.cwd,
4149
- stdio: "ignore"
4150
- });
4151
- return true;
4152
- } catch {
4153
- return false;
4154
- }
4269
+ if (!isValidPort(wsPort)) {
4270
+ error("Security validation failed");
4271
+ error(`Invalid WebSocket port: ${wsPort}. Must be between 1 and 65535.`);
4272
+ process.exit(1);
4155
4273
  }
4156
- async fetch(remote = "origin") {
4274
+ printHeader("Starting Taskin Dashboard", "\u{1F4CA}");
4275
+ try {
4276
+ info("Initializing task provider...");
4277
+ let currentDir = process.cwd();
4278
+ let tasksDir = path8.join(currentDir, "TASKS");
4157
4279
  try {
4158
- execSync3(`git fetch ${remote}`, {
4159
- cwd: this.cwd,
4160
- stdio: "ignore"
4161
- });
4162
- return true;
4280
+ await import("fs").then((fs6) => fs6.promises.access(tasksDir));
4163
4281
  } catch {
4164
- return false;
4282
+ while (currentDir !== path8.dirname(currentDir)) {
4283
+ const workspaceFile = path8.join(currentDir, "pnpm-workspace.yaml");
4284
+ try {
4285
+ await import("fs").then((fs6) => fs6.promises.access(workspaceFile));
4286
+ tasksDir = path8.join(currentDir, "TASKS");
4287
+ break;
4288
+ } catch {
4289
+ currentDir = path8.dirname(currentDir);
4290
+ }
4291
+ }
4165
4292
  }
4166
- }
4167
- async rebase(branch) {
4168
- try {
4169
- execSync3(`git rebase ${branch}`, {
4170
- cwd: this.cwd,
4171
- stdio: "ignore"
4172
- });
4173
- return true;
4174
- } catch {
4175
- return false;
4293
+ info(`Using tasks directory: ${tasksDir}`);
4294
+ const monorepoRoot = path8.dirname(tasksDir);
4295
+ const { provider } = await resolveTaskProvider({ cwd: monorepoRoot });
4296
+ const manager = new TaskManager(provider);
4297
+ info(`Starting WebSocket server on ${host}:${wsPort}...`);
4298
+ const wsServer = new TaskWebSocketServer({
4299
+ taskManager: manager,
4300
+ taskProvider: provider,
4301
+ options: {
4302
+ port: wsPort,
4303
+ host
4304
+ }
4305
+ });
4306
+ await wsServer.start();
4307
+ success(`\u2713 WebSocket server running on ws://${host}:${wsPort}`);
4308
+ info(`Starting dashboard server on http://${host}:${port}...`);
4309
+ const isDev = __dirname2.includes("/src/");
4310
+ const dashboardDist = isDev ? path8.join(__dirname2, "..", "..", "dashboard-dist") : path8.join(__dirname2, "..", "dashboard-dist");
4311
+ const app = express();
4312
+ app.disable("x-powered-by");
4313
+ app.use((_req, res, next) => {
4314
+ res.setHeader("X-Frame-Options", "DENY");
4315
+ res.setHeader("X-Content-Type-Options", "nosniff");
4316
+ res.setHeader("X-XSS-Protection", "1; mode=block");
4317
+ res.setHeader(
4318
+ "Content-Security-Policy",
4319
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:;"
4320
+ );
4321
+ next();
4322
+ });
4323
+ app.use((req, res, next) => {
4324
+ if (req.path === "/" || req.path === "/index.html") {
4325
+ import("fs").then((fs6) => fs6.promises.readFile(path8.join(dashboardDist, "index.html"), "utf-8")).then((html) => {
4326
+ const safeHost = escapeHtml(host);
4327
+ const safeWsPort = escapeHtml(String(wsPort));
4328
+ const injectedHtml = html.replace(
4329
+ "</head>",
4330
+ `<script>window.VITE_WS_URL = 'ws://${safeHost}:${safeWsPort}';</script></head>`
4331
+ );
4332
+ res.send(injectedHtml);
4333
+ }).catch((err) => {
4334
+ console.error("Failed to read index.html:", err);
4335
+ res.status(500).send("Internal Server Error");
4336
+ });
4337
+ } else {
4338
+ next();
4339
+ }
4340
+ });
4341
+ app.use(
4342
+ express.static(dashboardDist, {
4343
+ dotfiles: "deny",
4344
+ // Deny access to dotfiles
4345
+ index: false,
4346
+ // Don't serve index.html here (handled above)
4347
+ redirect: false
4348
+ // Don't redirect to trailing slash
4349
+ })
4350
+ );
4351
+ app.use((_req, res) => {
4352
+ res.status(404).send("Not Found");
4353
+ });
4354
+ const { server: httpServer, port: actualPort } = await startHttpServer(app, port, host);
4355
+ if (actualPort !== port) {
4356
+ warning(`Port ${port} was in use. Dashboard started on port ${actualPort}.`);
4176
4357
  }
4177
- }
4178
- async push(branch, remote = "origin") {
4179
- try {
4180
- execSync3(`git push ${remote} ${branch}`, {
4181
- cwd: this.cwd,
4182
- stdio: "ignore"
4183
- });
4184
- return true;
4185
- } catch {
4186
- return false;
4358
+ success(`\u2713 Dashboard available at http://${host}:${actualPort}`);
4359
+ const filterParams = new URLSearchParams();
4360
+ if (options.open) {
4361
+ filterParams.set("filter", "open");
4362
+ } else if (options.closed) {
4363
+ filterParams.set("filter", "closed");
4187
4364
  }
4188
- }
4189
- async abortRebase() {
4190
- try {
4191
- execSync3("git rebase --abort", {
4192
- cwd: this.cwd,
4193
- stdio: "ignore"
4365
+ const filterQuery = filterParams.toString() ? `?${filterParams.toString()}` : "";
4366
+ if (options.browser) {
4367
+ const url = `http://${host}:${actualPort}${filterQuery}`;
4368
+ await import("child_process").then((cp) => {
4369
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
4370
+ cp.exec(`${cmd} ${url}`);
4194
4371
  });
4195
- return true;
4196
- } catch {
4197
- return false;
4198
4372
  }
4199
- }
4200
- async checkoutFile(branch, pattern) {
4201
- try {
4202
- execSync3(`git checkout ${branch} -- ${pattern}`, {
4203
- cwd: this.cwd,
4204
- stdio: "ignore"
4373
+ info("");
4374
+ info(chalk4.bold("Dashboard Controls:"));
4375
+ info(` \u2022 Dashboard: ${chalk4.cyan(`http://${host}:${actualPort}${filterQuery}`)}`);
4376
+ info(` \u2022 WebSocket: ${chalk4.cyan(`ws://${host}:${wsPort}`)}`);
4377
+ if (options.open) {
4378
+ info(` \u2022 Filter: ${chalk4.yellow("Open tasks only")}`);
4379
+ } else if (options.closed) {
4380
+ info(` \u2022 Filter: ${chalk4.yellow("Closed tasks only")}`);
4381
+ }
4382
+ info(` \u2022 Press ${chalk4.bold("Ctrl+C")} to stop both servers`);
4383
+ info("");
4384
+ const cleanup = async () => {
4385
+ info("\nShutting down servers...");
4386
+ await new Promise((resolve) => {
4387
+ httpServer.close(() => resolve());
4205
4388
  });
4206
- return true;
4207
- } catch {
4208
- return false;
4389
+ await wsServer.stop();
4390
+ success("\u2713 Servers stopped");
4391
+ process.exit(0);
4392
+ };
4393
+ process.on("SIGINT", cleanup);
4394
+ process.on("SIGTERM", cleanup);
4395
+ } catch (err) {
4396
+ error("Failed to start dashboard");
4397
+ if (err instanceof Error) {
4398
+ error(err.message);
4209
4399
  }
4400
+ process.exit(1);
4210
4401
  }
4211
- };
4212
-
4213
- // ../git-utils/src/git-service.types.ts
4214
- init_esm_shims();
4402
+ }
4215
4403
 
4216
4404
  // src/commands/export.ts
4405
+ init_esm_shims();
4406
+ init_src3();
4407
+ init_src();
4217
4408
  import fs5 from "fs/promises";
4218
4409
  import path9 from "path";
4219
4410
  function userStatsToCsv(stats) {
@@ -4291,7 +4482,8 @@ function registerExportCommand(program2) {
4291
4482
 
4292
4483
  // src/commands/finish.ts
4293
4484
  init_esm_shims();
4294
- init_src2();
4485
+ init_src3();
4486
+ init_src();
4295
4487
  import { execSync as execSync5 } from "child_process";
4296
4488
 
4297
4489
  // src/lib/notification/notify-helper.ts
@@ -4713,6 +4905,14 @@ async function finishTask(taskId, options, gitService) {
4713
4905
  }
4714
4906
  info(`Found task: ${task.title}`);
4715
4907
  info(`Current status: ${task.status}`);
4908
+ const configManager = new ConfigManager(monorepoRoot);
4909
+ const behavior = configManager.getAutomationBehavior();
4910
+ const autoSyncActive = behavior.autoSync && !!behavior.defaultBranch;
4911
+ const statusCommitMessage = buildTaskStatusCommitMessage({
4912
+ taskId: normalizedId,
4913
+ status: "done",
4914
+ ciSkipTag: behavior.ciSkipTag
4915
+ });
4716
4916
  if (options.dryRun) {
4717
4917
  console.log();
4718
4918
  info("\u{1F50D} Dry run mode - showing what would be executed:");
@@ -4724,7 +4924,7 @@ async function finishTask(taskId, options, gitService) {
4724
4924
  info("Git operations:");
4725
4925
  console.log(
4726
4926
  colors.secondary(
4727
- ` - Commit status: git add TASKS/task-${normalizedId}-*.md && git commit -m "docs(TASKS): task-${normalizedId} - atualiza status para done [skip-ci]"`
4927
+ ` - Commit status: git add TASKS/task-${normalizedId}-*.md && git commit -m "${statusCommitMessage}"`
4728
4928
  )
4729
4929
  );
4730
4930
  console.log(
@@ -4741,13 +4941,10 @@ async function finishTask(taskId, options, gitService) {
4741
4941
  error("Task is already done");
4742
4942
  process.exit(1);
4743
4943
  }
4744
- const configManager = new ConfigManager(monorepoRoot);
4745
- const behavior = configManager.getAutomationBehavior();
4746
- const autoSyncActive = behavior.autoSync && !!behavior.defaultBranch;
4747
4944
  if (behavior.autoSync && !behavior.defaultBranch) {
4748
4945
  warning("autoSync is enabled but no defaultBranch is configured. Nothing will be synced.");
4749
4946
  }
4750
- const git = gitService ?? new GitService(process.cwd());
4947
+ const git = gitService ?? new GitService(process.cwd(), { ciSkipTag: behavior.ciSkipTag });
4751
4948
  if (!options.skipUpdate) {
4752
4949
  info("Marking task as done...");
4753
4950
  const updatedTask = await taskManager.finishTask(task.id);
@@ -4764,7 +4961,8 @@ async function finishTask(taskId, options, gitService) {
4764
4961
  const squashed = await squashTaskFileOnDone(git, {
4765
4962
  taskId: normalizedId,
4766
4963
  defaultBranch: behavior.defaultBranch,
4767
- originBranch: behavior.originBranch
4964
+ originBranch: behavior.originBranch,
4965
+ ciSkipTag: behavior.ciSkipTag
4768
4966
  });
4769
4967
  if (squashed) {
4770
4968
  success(`\u2713 Squash commit pushed to ${behavior.originBranch}`);
@@ -4798,7 +4996,7 @@ async function finishTask(taskId, options, gitService) {
4798
4996
  if (!options.skipUpdate) {
4799
4997
  if (!behavior.autoCommitStatusChange) {
4800
4998
  steps.push(
4801
- `Commit the status change: git add TASKS/task-${normalizedId}-*.md && git commit -m "docs(TASKS): task-${normalizedId} - atualiza status para done [skip-ci]"`
4999
+ `Commit the status change: git add TASKS/task-${normalizedId}-*.md && git commit -m "${statusCommitMessage}"`
4802
5000
  );
4803
5001
  }
4804
5002
  steps.push("Review your changes");
@@ -4839,6 +5037,7 @@ async function finishTask(taskId, options, gitService) {
4839
5037
 
4840
5038
  // src/commands/init.ts
4841
5039
  init_esm_shims();
5040
+ init_src();
4842
5041
  import { existsSync as existsSync5, readdirSync, writeFileSync as writeFileSync2 } from "fs";
4843
5042
  import inquirer2 from "inquirer";
4844
5043
  import { join as join3 } from "path";
@@ -4929,6 +5128,7 @@ async function ensureProviderInstalled(provider) {
4929
5128
  init_esm_shims();
4930
5129
 
4931
5130
  // src/commands/init.ts
5131
+ var NO_CI_SKIP_TAG_KEYWORD2 = "none";
4932
5132
  var initCommand = defineCommand({
4933
5133
  name: "init",
4934
5134
  alias: "setup",
@@ -4941,6 +5141,10 @@ var initCommand = defineCommand({
4941
5141
  {
4942
5142
  flags: "-p, --provider <provider>",
4943
5143
  description: "Provider to use (fs, redmine, jira, github) - skips interactive prompt"
5144
+ },
5145
+ {
5146
+ flags: "--ci-skip-tag <tag>",
5147
+ description: `Tag appended to Taskin's own commits so they skip CI (default "${DEFAULT_CI_SKIP_TAG}"; "none" to run CI) - skips interactive prompt`
4944
5148
  }
4945
5149
  ],
4946
5150
  handler: async (options) => {
@@ -4999,8 +5203,14 @@ async function initializeTaskin(options) {
4999
5203
  await ensureProviderInstalled(selectedProvider);
5000
5204
  }
5001
5205
  const providerConfig = await setupProviderConfig(selectedProvider, cwd);
5206
+ const ciSkipTag = await resolveCiSkipTag(options.ciSkipTag);
5002
5207
  const config = {
5003
5208
  version: "1.0.3",
5209
+ automation: {
5210
+ level: "assisted",
5211
+ autoSync: true,
5212
+ ciSkipTag
5213
+ },
5004
5214
  provider: {
5005
5215
  type: selectedProvider.id,
5006
5216
  config: providerConfig
@@ -5027,6 +5237,51 @@ async function initializeTaskin(options) {
5027
5237
  info("For more information, run: taskin --help");
5028
5238
  console.log();
5029
5239
  }
5240
+ async function resolveCiSkipTag(fromFlag) {
5241
+ if (fromFlag !== void 0) {
5242
+ return normalizeCiSkipTag(fromFlag);
5243
+ }
5244
+ if (process.env.CI === "true") {
5245
+ return DEFAULT_CI_SKIP_TAG;
5246
+ }
5247
+ console.log();
5248
+ info("Taskin appends a tag to the commits it writes itself \u2014 status changes and");
5249
+ info("task files \u2014 so they do not trigger your pipeline.");
5250
+ console.log();
5251
+ const { choice } = await inquirer2.prompt([
5252
+ {
5253
+ type: "list",
5254
+ name: "choice",
5255
+ message: "Tag for Taskin commits:",
5256
+ default: DEFAULT_CI_SKIP_TAG,
5257
+ choices: [
5258
+ { name: `${CI_SKIP_TAGS[0]} \u2014 GitHub, GitLab and Bitbucket (recommended)`, value: CI_SKIP_TAGS[0] },
5259
+ { name: `${CI_SKIP_TAGS[1]} \u2014 GitHub, GitLab and Bitbucket`, value: CI_SKIP_TAGS[1] },
5260
+ { name: `${CI_SKIP_TAGS[2]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[2] },
5261
+ { name: `${CI_SKIP_TAGS[3]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[3] },
5262
+ { name: `${CI_SKIP_TAGS[4]} \u2014 GitHub Actions only`, value: CI_SKIP_TAGS[4] },
5263
+ { name: "none \u2014 do not mark the commits, let CI run", value: NO_CI_SKIP_TAG_KEYWORD2 },
5264
+ { name: "custom\u2026 \u2014 another CI (Azure DevOps uses ***NO_CI***)", value: "custom" }
5265
+ ]
5266
+ }
5267
+ ]);
5268
+ if (choice !== "custom") {
5269
+ return normalizeCiSkipTag(choice);
5270
+ }
5271
+ const { customTag } = await inquirer2.prompt([
5272
+ {
5273
+ type: "input",
5274
+ name: "customTag",
5275
+ message: "Tag to append:",
5276
+ default: DEFAULT_CI_SKIP_TAG
5277
+ }
5278
+ ]);
5279
+ return normalizeCiSkipTag(customTag);
5280
+ }
5281
+ function normalizeCiSkipTag(input) {
5282
+ const trimmed = input.trim();
5283
+ return trimmed.toLowerCase() === NO_CI_SKIP_TAG_KEYWORD2 ? "" : trimmed;
5284
+ }
5030
5285
  async function setupProviderConfig(provider, cwd) {
5031
5286
  if (provider.id === "fs") {
5032
5287
  return setupFileSystemProvider(cwd);
@@ -5051,7 +5306,7 @@ async function setupProviderConfig(provider, cwd) {
5051
5306
  }
5052
5307
  async function setupFileSystemProvider(cwd) {
5053
5308
  const tasksDir = join3(cwd, "TASKS");
5054
- const { DEFAULT_METADATA_STYLE_ID: DEFAULT_METADATA_STYLE_ID2, FileSystemTaskProvider: FileSystemTaskProvider2, getMetadataStyle: getMetadataStyle2, UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src2(), src_exports));
5309
+ const { DEFAULT_METADATA_STYLE_ID: DEFAULT_METADATA_STYLE_ID2, FileSystemTaskProvider: FileSystemTaskProvider2, getMetadataStyle: getMetadataStyle2, UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src3(), src_exports));
5055
5310
  const userRegistry = new UserRegistry2({ taskinDir: join3(cwd, ".taskin") });
5056
5311
  const fileSystemProvider = new FileSystemTaskProvider2(tasksDir, userRegistry);
5057
5312
  await fileSystemProvider.initialize();
@@ -5096,7 +5351,7 @@ You can edit or delete this file. Use \`taskin list\` to see all tasks.
5096
5351
  };
5097
5352
  }
5098
5353
  async function promptCreateFirstUser(cwd) {
5099
- const { UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src2(), src_exports));
5354
+ const { UserRegistry: UserRegistry2 } = await Promise.resolve().then(() => (init_src3(), src_exports));
5100
5355
  const taskinDir = join3(cwd, ".taskin");
5101
5356
  const userRegistry = new UserRegistry2({ taskinDir });
5102
5357
  const { createFirstUser } = await inquirer2.prompt([
@@ -5862,7 +6117,8 @@ async function startMCPServer(options) {
5862
6117
 
5863
6118
  // src/commands/new.ts
5864
6119
  init_esm_shims();
5865
- init_src2();
6120
+ init_src3();
6121
+ init_src();
5866
6122
  import { TASK_TYPES as TASK_TYPES2 } from "@opentask/taskin-types";
5867
6123
  import inquirer3 from "inquirer";
5868
6124
  var createCommand = defineCommand({
@@ -5964,7 +6220,7 @@ async function createTask(options, gitService) {
5964
6220
  if (behavior.autoSync && !behavior.defaultBranch) {
5965
6221
  warning("autoSync is enabled but no defaultBranch is configured. Nothing will be synced.");
5966
6222
  }
5967
- const git = gitService ?? new GitService(process.cwd());
6223
+ const git = gitService ?? new GitService(process.cwd(), { ciSkipTag: behavior.ciSkipTag });
5968
6224
  if (autoSyncActive) {
5969
6225
  try {
5970
6226
  await syncBeforeCreate(git, {
@@ -5996,7 +6252,8 @@ async function createTask(options, gitService) {
5996
6252
  await pushAfterCreate(git, {
5997
6253
  taskId,
5998
6254
  title: options.title,
5999
- defaultBranch: behavior.defaultBranch
6255
+ defaultBranch: behavior.defaultBranch,
6256
+ ciSkipTag: behavior.ciSkipTag
6000
6257
  });
6001
6258
  } catch (pushError) {
6002
6259
  error(
@@ -6216,6 +6473,7 @@ async function pauseTask(taskId, options) {
6216
6473
 
6217
6474
  // src/commands/review.ts
6218
6475
  init_esm_shims();
6476
+ init_src();
6219
6477
  import { execSync as execSync9 } from "child_process";
6220
6478
 
6221
6479
  // src/lib/hook-runner.ts
@@ -6441,10 +6699,14 @@ async function reviewTask(taskId, options) {
6441
6699
  success(`\u2713 Task ${updatedTask.id} status changed to: ${updatedTask.status}`);
6442
6700
  if (behavior.autoCommitStatusChange) {
6443
6701
  try {
6444
- execSync9(
6445
- `git add TASKS/task-${normalizedId}-*.md && git commit -m "docs(TASKS): task-${normalizedId} - mark as ready for review [skip-ci]"`,
6446
- { cwd: monorepoRoot, stdio: "ignore" }
6702
+ const message = appendCiSkipTag(
6703
+ `docs(TASKS): task-${normalizedId} - mark as ready for review`,
6704
+ behavior.ciSkipTag
6447
6705
  );
6706
+ execSync9(`git add TASKS/task-${normalizedId}-*.md && git commit -m "${message}"`, {
6707
+ cwd: monorepoRoot,
6708
+ stdio: "ignore"
6709
+ });
6448
6710
  success("\u2713 Auto-committed status change");
6449
6711
  } catch {
6450
6712
  }
@@ -6492,6 +6754,7 @@ async function reviewTask(taskId, options) {
6492
6754
 
6493
6755
  // src/commands/start.ts
6494
6756
  init_esm_shims();
6757
+ init_src();
6495
6758
  var startCommand = defineCommand({
6496
6759
  name: "start <task-id>",
6497
6760
  description: "\u{1F680} Start working on a task",
@@ -6536,6 +6799,13 @@ async function startTask(taskId, _options, gitService) {
6536
6799
  }
6537
6800
  info(`Found task: ${task.title}`);
6538
6801
  info(`Current status: ${task.status}`);
6802
+ const configManager = new ConfigManager(monorepoRoot);
6803
+ const behavior = configManager.getAutomationBehavior();
6804
+ const statusCommitMessage = buildTaskStatusCommitMessage({
6805
+ taskId: normalizedId,
6806
+ status: "in-progress",
6807
+ ciSkipTag: behavior.ciSkipTag
6808
+ });
6539
6809
  if (_options.dryRun) {
6540
6810
  console.log();
6541
6811
  info("\u{1F50D} Dry run mode - showing what would be executed:");
@@ -6547,7 +6817,7 @@ async function startTask(taskId, _options, gitService) {
6547
6817
  console.log(colors.secondary(` - Create branch: git checkout -b feat/task-${normalizedId}`));
6548
6818
  console.log(
6549
6819
  colors.secondary(
6550
- ` - Commit status: git add TASKS/task-${normalizedId}-*.md && git commit -m "docs(TASKS): task-${normalizedId} - atualiza status para in-progress [skip-ci]"`
6820
+ ` - Commit status: git add TASKS/task-${normalizedId}-*.md && git commit -m "${statusCommitMessage}"`
6551
6821
  )
6552
6822
  );
6553
6823
  console.log();
@@ -6566,9 +6836,7 @@ async function startTask(taskId, _options, gitService) {
6566
6836
  const updatedTask = await taskManager.startTask(task.id);
6567
6837
  success(`Task ${updatedTask.id} started successfully!`);
6568
6838
  success(`Status changed to: ${updatedTask.status}`);
6569
- const configManager = new ConfigManager(monorepoRoot);
6570
- const behavior = configManager.getAutomationBehavior();
6571
- const git = gitService ?? new GitService(process.cwd());
6839
+ const git = gitService ?? new GitService(process.cwd(), { ciSkipTag: behavior.ciSkipTag });
6572
6840
  if (behavior.autoCommitStatusChange) {
6573
6841
  const committed = await git.commitTaskStatusChangeOnBranch(normalizedId, "in-progress", behavior.defaultBranch);
6574
6842
  if (committed) {
@@ -6580,7 +6848,7 @@ async function startTask(taskId, _options, gitService) {
6580
6848
  info("Next steps (suggestions):");
6581
6849
  console.log(
6582
6850
  colors.secondary(
6583
- ` 1. Commit the status change: git add TASKS/task-${normalizedId}-*.md && git commit -m "docs(TASKS): task-${normalizedId} - atualiza status para in-progress [skip ci]"`
6851
+ ` 1. Commit the status change: git add TASKS/task-${normalizedId}-*.md && git commit -m "${statusCommitMessage}"`
6584
6852
  )
6585
6853
  );
6586
6854
  console.log(colors.secondary(` 2. Create a branch: git checkout -b feat/task-${normalizedId}`));
@@ -6601,7 +6869,8 @@ async function startTask(taskId, _options, gitService) {
6601
6869
 
6602
6870
  // src/commands/stats.ts
6603
6871
  init_esm_shims();
6604
- init_src2();
6872
+ init_src3();
6873
+ init_src();
6605
6874
  import chalk7 from "chalk";
6606
6875
  import path11 from "path";
6607
6876
  var statsCommand = defineCommand({
@@ -6943,7 +7212,7 @@ init_esm_shims();
6943
7212
 
6944
7213
  // src/lib/file-system-task-linter/file-system-task-linter.ts
6945
7214
  init_esm_shims();
6946
- init_src2();
7215
+ init_src3();
6947
7216
  import { TASK_STATUSES as TASK_STATUSES3, TASK_TYPES as TASK_TYPES3 } from "@opentask/taskin-types";
6948
7217
  import chalk8 from "chalk";
6949
7218
  import { readdir, readFile as readFile2 } from "fs/promises";