+31
-2
@@ -412,3 +412,26 @@ /** | ||
| } | ||
| // ─── read ──────────────────────────────────────────────────────────────────── | ||
| const AUTH_PROBE_TIMEOUT_MS = 3000; | ||
| /** | ||
| * Cheap auth check so `status` never presents a cached board as live when the | ||
| * stored token is dead. Any non-401/403 server response counts as ok (we only | ||
| * care whether the token is accepted); network failure means offline. | ||
| */ | ||
| async function probeAuth(cfg) { | ||
| const token = resolveToken(); | ||
| if (!token) | ||
| return "unauthorized"; | ||
| const ctrl = new AbortController(); | ||
| const timer = setTimeout(() => ctrl.abort(), AUTH_PROBE_TIMEOUT_MS); | ||
| try { | ||
| const res = await fetch(`${cfg.apiBase.replace(/\/+$/, "")}/api/projects/${encodeURIComponent(cfg.projectId)}`, { headers: { "x-api-key": token }, signal: ctrl.signal }); | ||
| res.body?.cancel().catch(() => { }); | ||
| return res.status === 401 || res.status === 403 ? "unauthorized" : "ok"; | ||
| } | ||
| catch { | ||
| return "offline"; | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| export async function status(_args) { | ||
@@ -418,2 +441,3 @@ const cfg = requireRepoConfig(); | ||
| const age = boardAge(); | ||
| const auth = await probeAuth(cfg); | ||
| const counts = groupByStatus(ctx.tasks); | ||
@@ -438,6 +462,11 @@ print(""); | ||
| print(""); | ||
| info(color.dim(age ? `Synced ${age.human}. Run \`taskpod pull\` to refresh.` : "Not yet synced.")); | ||
| const offlineNote = auth === "offline" ? " (offline — cached)" : ""; | ||
| info(color.dim(age ? `Synced ${age.human}${offlineNote}. Run \`taskpod pull\` to refresh.` : "Not yet synced.")); | ||
| if (age && Date.now() - age.mtime.getTime() > 30 * 60 * 1000) { | ||
| warn("Local board is over 30 minutes old — run `taskpod pull`."); | ||
| } | ||
| if (auth === "unauthorized") { | ||
| warn(`⚠ Auth failed — showing cached board synced ${age ? age.human : "at an unknown time"}; run \`taskpod login\`.`); | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
@@ -444,0 +473,0 @@ export async function mine(args) { |
+67
-16
@@ -22,3 +22,3 @@ /** | ||
| import { parseRepoFullName } from "./commands.js"; | ||
| import { CliError, color, flagBool, flagStr, info, print, success, } from "./util.js"; | ||
| import { CliError, color, flagBool, flagStr, info, print, success, warn, } from "./util.js"; | ||
| // ─── tunables ──────────────────────────────────────────────────────────────── | ||
@@ -30,3 +30,4 @@ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".taskpod"]); | ||
| export const DEFAULT_TODO_LIMIT = 20; | ||
| const MAX_TODO_LINE_CHARS = 200; | ||
| /** Long TODO lines are truncated to this many chars in the task description. */ | ||
| const MAX_TODO_SNIPPET_CHARS = 500; | ||
| const TODO_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".py", ".go", ".rs", ".java", ".rb"]); | ||
@@ -85,5 +86,23 @@ const CONTRIBUTING_LINES = 40; | ||
| } | ||
| /** readText, but records an honest skip reason when the file exists yet is over the size cap. */ | ||
| function readDoc(absPath, origin, skipped) { | ||
| let buf; | ||
| try { | ||
| buf = readFileSync(absPath); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| if (buf.length > MAX_DOC_BYTES) { | ||
| skipped.push({ origin, reason: `larger than the ${Math.round(MAX_DOC_BYTES / 1024)}KB cap` }); | ||
| return null; | ||
| } | ||
| if (buf.subarray(0, 8192).includes(0)) | ||
| return null; // binary | ||
| return buf.toString("utf8"); | ||
| } | ||
| function scanDocs(cwd, files) { | ||
| const docs = []; | ||
| const readme = readText(join(cwd, "README.md")); | ||
| const skipped = []; | ||
| const readme = readDoc(join(cwd, "README.md"), "README.md", skipped); | ||
| if (readme?.trim()) { | ||
@@ -94,7 +113,7 @@ docs.push({ title: "Project overview", content: readme, origin: "README.md" }); | ||
| for (const f of mdFiles.slice(0, MAX_DOC_FILES)) { | ||
| const content = readText(f.absPath); | ||
| const content = readDoc(f.absPath, f.relPath, skipped); | ||
| if (content?.trim()) | ||
| docs.push({ title: f.relPath, content, origin: f.relPath }); | ||
| } | ||
| return docs; | ||
| return { docs, skipped }; | ||
| } | ||
@@ -112,3 +131,7 @@ const FRAMEWORK_LABELS = { | ||
| const TEST_RUNNERS = ["vitest", "jest", "mocha", "ava", "@playwright/test", "cypress"]; | ||
| function stackLearning(pkg) { | ||
| /** "@playwright/test" → "playwright"; plain names pass through. */ | ||
| function runnerCommand(dep) { | ||
| return dep.startsWith("@") ? dep.slice(1).split("/")[0] : dep; | ||
| } | ||
| function stackLearning(pkg, tsInRepo) { | ||
| const deps = (pkg.dependencies ?? {}); | ||
@@ -120,4 +143,9 @@ const devDeps = (pkg.devDependencies ?? {}); | ||
| .map((d) => FRAMEWORK_LABELS[d]); | ||
| const typescript = "typescript" in all; | ||
| const testRunners = TEST_RUNNERS.filter((d) => d in all); | ||
| // TypeScript can be present without a package.json dep (tsconfig, .ts sources). | ||
| const typescript = "typescript" in all || tsInRepo; | ||
| // Test runner: declared as a dep OR invoked from a package.json script. | ||
| const scriptText = Object.values(pkg.scripts ?? {}) | ||
| .filter((v) => typeof v === "string") | ||
| .join(" "); | ||
| const testRunners = TEST_RUNNERS.filter((d) => d in all || new RegExp(`\\b${runnerCommand(d)}\\b`).test(scriptText)); | ||
| const lint = []; | ||
@@ -215,3 +243,6 @@ if ("eslint" in all) | ||
| if (pkg) { | ||
| const stack = stackLearning(pkg); | ||
| const tsInRepo = files.some((f) => f.relPath === "tsconfig.json" || | ||
| f.relPath.endsWith("/tsconfig.json") || | ||
| /\.(ts|tsx)$/.test(f.relPath)); | ||
| const stack = stackLearning(pkg, tsInRepo); | ||
| if (stack) | ||
@@ -249,4 +280,2 @@ learnings.push(stack); | ||
| const line = lines[i]; | ||
| if (line.length > MAX_TODO_LINE_CHARS) | ||
| continue; | ||
| const m = TODO_RE.exec(line); | ||
@@ -257,4 +286,12 @@ if (!m) | ||
| tasks.push({ | ||
| title: truncate(`TODO: ${m[2].trim()}`, TASK_TITLE_MAX), | ||
| description: [`\`${origin}\``, "", "```", line.trim(), "```", "", "Imported by taskpod ingest."].join("\n"), | ||
| title: truncate(`${m[1]}: ${m[2].trim()}`, TASK_TITLE_MAX), | ||
| description: [ | ||
| `\`${origin}\``, | ||
| "", | ||
| "```", | ||
| truncate(line.trim(), MAX_TODO_SNIPPET_CHARS), | ||
| "```", | ||
| "", | ||
| "Imported by taskpod ingest.", | ||
| ].join("\n"), | ||
| origin, | ||
@@ -277,6 +314,8 @@ }); | ||
| const files = walkFiles(cwd); | ||
| const { docs, skipped } = scanDocs(cwd, files); | ||
| return { | ||
| docs: scanDocs(cwd, files), | ||
| docs, | ||
| learnings: scanLearnings(cwd, files), | ||
| tasks: scanTodos(files, limitTasks), | ||
| skippedDocs: skipped, | ||
| remote: gitRemoteOrigin(cwd), | ||
@@ -362,2 +401,3 @@ }; | ||
| tasks: plan.tasks.map((t) => ({ item: t, exists: existing.taskTitles.has(t.title.toLowerCase()) })), | ||
| skippedDocs: plan.skippedDocs, | ||
| resource, | ||
@@ -377,6 +417,10 @@ }; | ||
| print(color.bold(`Wiki docs — ${pending(d.docs)} to create, ${d.docs.length - pending(d.docs)} existing`)); | ||
| if (d.docs.length === 0) | ||
| if (d.docs.length === 0 && d.skippedDocs.length === 0) { | ||
| print(color.dim(" (no README.md or docs/**/*.md found)")); | ||
| } | ||
| for (const x of d.docs) | ||
| print(planLine(x.item.title, x.exists, x.item.origin)); | ||
| for (const s of d.skippedDocs) { | ||
| print(` ${color.yellow("!")} ${s.origin}${color.dim(` (skipped — ${s.reason})`)}`); | ||
| } | ||
| print(""); | ||
@@ -452,3 +496,7 @@ print(color.bold(`Learnings — ${pending(d.learnings)} to record, ${d.learnings.length - pending(d.learnings)} existing`)); | ||
| const plan = scanRepo(cwd, opts.limitTasks ?? DEFAULT_TODO_LIMIT); | ||
| if (plan.docs.length === 0 && plan.learnings.length === 0 && plan.tasks.length === 0 && !plan.remote) { | ||
| if (plan.docs.length === 0 && | ||
| plan.skippedDocs.length === 0 && | ||
| plan.learnings.length === 0 && | ||
| plan.tasks.length === 0 && | ||
| !plan.remote) { | ||
| info("Nothing to ingest — no README, docs/, package.json, .env.example, or TODO comments found."); | ||
@@ -474,2 +522,5 @@ return; | ||
| success(`Wiki: ${docs.created} page${docs.created === 1 ? "" : "s"} created${skipNote(docs.skipped)}`); | ||
| for (const s of decided.skippedDocs) { | ||
| warn(`Doc skipped: ${s.origin} — ${s.reason}.`); | ||
| } | ||
| success(`Learnings: ${learnings.created} recorded${skipNote(learnings.skipped)}`); | ||
@@ -476,0 +527,0 @@ success(`Tasks: ${tasks.created} created in BACKLOG${skipNote(tasks.skipped)}`); |
@@ -267,2 +267,16 @@ /** | ||
| // ─── LEARNINGS.md ──────────────────────────────────────────────────────────── | ||
| /** | ||
| * Strip control characters (C0 except \t/\n, DEL, C1 — the ANSI/terminal-escape | ||
| * range) from server-sourced text before writing it into .taskpod/ files, so a | ||
| * malicious row can never inject escape sequences into an agent's context. | ||
| * Keeps newlines; normalizes \r\n and strips bare \r. | ||
| */ | ||
| function cleanBlock(s) { | ||
| // eslint-disable-next-line no-control-regex | ||
| return s.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ""); | ||
| } | ||
| /** cleanBlock + collapse to a single line, for headings and metadata. */ | ||
| function cleanInline(s) { | ||
| return cleanBlock(s).replace(/\s+/g, " ").trim(); | ||
| } | ||
| const LEARNING_KIND_ORDER = ["decision", "convention", "gotcha", "learning"]; | ||
@@ -306,4 +320,8 @@ const LEARNING_KIND_HEADING = { | ||
| for (const l of ls) { | ||
| lines.push(`### ${l.title || "(untitled)"}`); | ||
| const meta = [l.authorName, l.createdAt ? String(l.createdAt).slice(0, 10) : null, l.source] | ||
| lines.push(`### ${cleanInline(l.title ?? "") || "(untitled)"}`); | ||
| const meta = [ | ||
| l.authorName ? cleanInline(l.authorName) : null, | ||
| l.createdAt ? cleanInline(String(l.createdAt).slice(0, 10)) : null, | ||
| l.source ? cleanInline(l.source) : null, | ||
| ] | ||
| .filter(Boolean) | ||
@@ -315,3 +333,3 @@ .join(" · "); | ||
| if (l.content && l.content !== l.title) { | ||
| lines.push(l.content.trim()); | ||
| lines.push(cleanBlock(l.content).trim()); | ||
| lines.push(""); | ||
@@ -318,0 +336,0 @@ } |
+1
-1
@@ -27,3 +27,3 @@ /** | ||
| export const SERVER_NAME = "taskpod"; | ||
| export const SERVER_VERSION = "0.4.5"; | ||
| export const SERVER_VERSION = "0.4.6"; | ||
| export const allTools = [ | ||
@@ -30,0 +30,0 @@ ...teamTools, |
+47
-13
@@ -6,2 +6,6 @@ import { z } from "zod"; | ||
| // ─── compact context digest ────────────────────────────────────────────────── | ||
| /** Max chars of a learning's content shown in the digest, per entry. */ | ||
| const LEARNING_EXCERPT_CHARS = 160; | ||
| /** Soft cap on the whole "Recent learnings" section, to keep the digest compact. */ | ||
| const LEARNING_SECTION_CHARS = 1200; | ||
| const DIGEST_STATUS_ORDER = [ | ||
@@ -17,9 +21,21 @@ "IN_PROGRESS", | ||
| ]; | ||
| /** | ||
| * Sanitize server-sourced text before interpolating it into the digest: strip | ||
| * control characters (C0, DEL, C1 — the ANSI/terminal-escape range) and | ||
| * collapse whitespace to single spaces, so a malicious row can never inject | ||
| * escape sequences into an agent's context even if the server misses it. | ||
| */ | ||
| function clean(s) { | ||
| if (!s) | ||
| return ""; | ||
| // eslint-disable-next-line no-control-regex | ||
| return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim(); | ||
| } | ||
| function ticketLabel(t) { | ||
| return t.ticketId ?? t.id.slice(0, 8); | ||
| return clean(t.ticketId) || t.id.slice(0, 8); | ||
| } | ||
| function ownerLabel(t) { | ||
| if (t.ownerType === "AGENT" || (t.agentId && !t.assigneeId)) | ||
| return t.agentName ?? "agent"; | ||
| return t.assigneeName ?? (t.assigneeId ? "assigned" : "unassigned"); | ||
| return clean(t.agentName) || "agent"; | ||
| return clean(t.assigneeName) || (t.assigneeId ? "assigned" : "unassigned"); | ||
| } | ||
@@ -43,11 +59,12 @@ function statusCounts(tasks) { | ||
| const lines = []; | ||
| lines.push(`# ${p.name ?? "Project"}${p.key ? ` (${p.key})` : ""} — live Taskpod context`); | ||
| lines.push(`# ${clean(p.name) || "Project"}${p.key ? ` (${clean(p.key)})` : ""} — live Taskpod context`); | ||
| if (p.description) | ||
| lines.push(p.description.trim().split("\n")[0]); | ||
| lines.push(clean(p.description.split("\n")[0])); | ||
| lines.push(`Project ID: ${p.id}`); | ||
| if (ctx.sprint) { | ||
| const goal = clean(ctx.sprint.goal); | ||
| const bits = [ | ||
| ctx.sprint.name, | ||
| ctx.sprint.goal ? `goal: ${ctx.sprint.goal}` : null, | ||
| ctx.sprint.endsAt ? `ends ${String(ctx.sprint.endsAt).slice(0, 10)}` : null, | ||
| clean(ctx.sprint.name), | ||
| goal ? `goal: ${goal}` : null, | ||
| ctx.sprint.endsAt ? `ends ${clean(String(ctx.sprint.endsAt).slice(0, 10))}` : null, | ||
| ].filter(Boolean); | ||
@@ -58,6 +75,6 @@ if (bits.length) | ||
| if (ctx.initiative?.name) { | ||
| lines.push(`Initiative: ${ctx.initiative.name}${ctx.initiative.status ? ` (${ctx.initiative.status})` : ""}`); | ||
| lines.push(`Initiative: ${clean(ctx.initiative.name)}${ctx.initiative.status ? ` (${clean(ctx.initiative.status)})` : ""}`); | ||
| } | ||
| if (ctx.workflow.length > 0) { | ||
| lines.push(`Board columns: ${ctx.workflow.map((c) => c.name).join(" → ")}`); | ||
| lines.push(`Board columns: ${ctx.workflow.map((c) => clean(c.name)).join(" → ")}`); | ||
| } | ||
@@ -71,3 +88,3 @@ lines.push(""); | ||
| const blocked = t.blockedReason || t.blockedBy.length > 0 ? " [BLOCKED]" : ""; | ||
| lines.push(`- ${ticketLabel(t)} [${t.status}] ${t.title ?? ""} — ${ownerLabel(t)}${blocked}`); | ||
| lines.push(`- ${ticketLabel(t)} [${clean(t.status)}] ${clean(t.title)} — ${ownerLabel(t)}${blocked}`); | ||
| } | ||
@@ -77,4 +94,21 @@ if (ctx.learnings.length > 0) { | ||
| lines.push("Recent learnings:"); | ||
| let sectionChars = 0; | ||
| for (const l of ctx.learnings.slice(0, 5)) { | ||
| lines.push(`- [${l.kind ?? "learning"}] ${l.title ?? l.content ?? ""}`); | ||
| const title = clean(l.title); | ||
| const content = clean(l.content); | ||
| let excerpt = ""; | ||
| if (content && content !== title) { | ||
| excerpt = | ||
| content.length > LEARNING_EXCERPT_CHARS | ||
| ? `${content.slice(0, LEARNING_EXCERPT_CHARS)}…` | ||
| : content; | ||
| } | ||
| const head = title || excerpt; | ||
| if (!head) | ||
| continue; | ||
| const line = `- [${clean(l.kind) || "learning"}] ${head}${title && excerpt ? ` — ${excerpt}` : ""}`; | ||
| if (sectionChars + line.length > LEARNING_SECTION_CHARS) | ||
| break; | ||
| lines.push(line); | ||
| sectionChars += line.length + 1; | ||
| } | ||
@@ -84,3 +118,3 @@ } | ||
| lines.push(""); | ||
| lines.push(`Wiki pages: ${ctx.wiki.map((w) => w.title).join(" · ")}`); | ||
| lines.push(`Wiki pages: ${ctx.wiki.map((w) => clean(w.title)).join(" · ")}`); | ||
| } | ||
@@ -87,0 +121,0 @@ lines.push(""); |
+1
-1
| { | ||
| "name": "taskpod", | ||
| "version": "0.4.5", | ||
| "version": "0.4.6", | ||
| "description": "The Taskpod developer CLI and MCP server — sync project context to your machine and AI, and manage teams, projects, and tasks from any MCP client.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
567332
0.96%5479
2.47%45
-2.17%11
10%