New:Socket for Asana Is Now Available.Learn more
Get Started

taskpod

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

taskpod - npm Package Compare versions

Comparing version
0.4.5
to
0.4.6
+31
-2
dist/cli/commands.js

@@ -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) {

@@ -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,

@@ -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("");

{
"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",