Skill: safatinaztepe/project-router (version 1.0.0)

--- name: project-router description: "Terminal-first project bootstrapper and workspace context manager. Use when the user asks for /project-style workflows: detect current project, read project context/brief, run standardized targets (build/test/lint/deploy), init a .project bundle via plan/apply, manage artifacts, or expose these actions via MCP server mcp-project-router and CLI project." --- # project-router This skill is Safa’s **canonical project management + context switching control plane**. Core idea: - **Canonical PM is local + queryable (SQLite)**: projects, tasks, context packs. - **Trello is a tracking backend/UI**: cards mirror canonical tasks; lists mirror status; labels mirror priority. - The “killer feature” is **context switching**: load the right docs/code/index for a project/task quickly and deterministically. It provides: - CLI: `project <verb> ...` - MCP server: `mcp-project-router` (tools mirror the CLI) - Per-project bundle stored in `.project/` (brief, targets, artifact index) - A canonical task store (SQLite) + Trello sync adapter ## Project bundle layout (v1) The `.project/` bundle is the **project-local** context nucleus. The canonical PM DB points at these bundles. Inside a project root: - `.project/project.json` — structured manifest - `.project/PROJECT.md` — living brief - `.project/targets.json` — target definitions (commands) - `.project/index/artifacts.json` — artifact index - `.project/history/plans/*.json` — plans - `.project/history/applies/*.json` — apply receipts ## CLI quick start ### Baseline / existing commands From anywhere inside a repo/workspace: - `project detect` - `project context` - `project target list` - `project target run <name>` Initialize a bundle (dry-run plan + apply): - `project init` (prints plan) - `project apply <planId>` Artifacts: - `project artifact add <path|url> [--tags a,b,c]` (plan + apply) ### Canonical PM + context switching (new) > Note: these verbs are the target UX. Implementations should remain idempotent and safe. Project registration: - `project pm project add <slug> --name "..." --root <path>` - `project pm project list` Task management: - `project pm task add <slug> "<title>" --priority P0|P1|P2|P3 [--status inbox|next|doing|blocked|waiting|done]` - `project pm task list [--project <slug>] [--status ...]` - `project pm task set-status <taskId> <status>` Context switching: - `project pm switch <slug>` - prints pinned docs + top targets + active tasks - `project pm focus <taskId>` - loads task-linked files/artifacts and updates the task activity log Trello sync: - `project pm trello sync [--project <slug>]` - ensures the single "Safa — PM" Trello board exists - ensures lists exist (Inbox/Next/Doing/Blocked/Waiting/Done) - upserts cards for canonical tasks - moves cards to match status - applies priority labels (P0..P3) ## MCP quick start (via mcporter) - `mcporter list mcp-project-router --schema --timeout 120000 --json` Examples: - Detect: - `mcporter call --server mcp-project-router --tool project_detect --args '{}'` - Read context: - `mcporter call --server mcp-project-router --tool project_context_read --args '{}'` - Run target: - `mcporter call --server mcp-project-router --tool project_target_run --args '{"target":"test"}'` ## Trello backend conventions Single-board setup: - Board name: `Safa — PM` (or configurable) - Lists == canonical statuses: - `Inbox`, `Next`, `Doing`, `Blocked`, `Waiting`, `Done` - Card title: `[<project_slug>] <task_title>` - Card description begins with a machine block for idempotency: ```yaml --- pm --- task_id: <stable-id> project: <slug> status: <status> priority: P0|P1|P2|P3 --- ``` - Labels (priority, color-coded): - `P0` = red - `P1` = orange - `P2` = yellow - `P3` = blue ## Canonical PM storage (SQLite) Recommended DB location (in workspace): - `/home/safa/clawd/data/pm/pm.sqlite` Minimum tables (v0): - `projects(slug PRIMARY KEY, name, root_path, created_at, updated_at)` - `tasks(task_id PRIMARY KEY, project_slug, title, status, priority, created_at, updated_at)` - `task_refs(task_id, kind, ref)` (file paths / urls / artifacts) - `external_refs(task_id, system, external_id, meta_json)` (e.g., Trello card_id/list_id) ## Safety - Project bundle writes remain **plan/apply**. - Canonical PM writes should be idempotent and auditable (timestamps + activity log). - Trello sync should be safe to re-run repeatedly (upsert by `task_id` marker; never duplicate cards). - `project_target_run` executes commands defined in `.project/targets.json`.

path: scripts/project.js
content: #!/usr/bin/env node
/*
 * project CLI (v1)
 *
 * Minimal, dependency-free implementation of the design doc.
 *
 * Usage:
 *   project detect
 *   project context
 *   project target list
 *   project target run <target>
 *   project init
 *   project apply <planId>
 *   project artifact add <path|url> [--tags a,b,c]
 *   project search keyword <term>
 */

const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

function die(msg, code = 1) {
  process.stderr.write(String(msg).trimEnd() + '\n');
  process.exit(code);
}

function nowIso() {
  return new Date().toISOString();
}

function readJson(p) {
  return JSON.parse(fs.readFileSync(p, 'utf8'));
}

function writeJson(p, obj) {
  fs.mkdirSync(path.dirname(p), { recursive: true });
  fs.writeFileSync(p, JSON.stringify(obj, null, 2));
}

function exists(p) {
  try {
    fs.accessSync(p);
    return true;
  } catch {
    return false;
  }
}

function findProjectRoot(startDir) {
  let dir = path.resolve(startDir || process.cwd());
  while (true) {
    const projDir = path.join(dir, '.project');
    if (exists(projDir)) return dir;

    // if git repo root contains .project
    if (exists(path.join(dir, '.git')) && exists(path.join(dir, '.project'))) return dir;

    const parent = path.dirname(dir);
    if (parent === dir) return null;
    dir = parent;
  }
}

function ensureBundle(root) {
  const projDir = path.join(root, '.project');
  if (!exists(projDir)) die(`No .project bundle found (run: project init) at or above ${process.cwd()}`);
  return projDir;
}

function loadTargets(root) {
  const projDir = ensureBundle(root);
  const tp = path.join(projDir, 'targets.json');
  if (!exists(tp)) return { version: 1, targets: {} };
  return readJson(tp);
}

function defaultInitPlan(root) {
  const planId = `plan-${Date.now()}`;
  const projDir = path.join(root, '.project');
  const slug = path.basename(root);

  const files = [
    {
      path: '.project/project.json',
      kind: 'json',
      content: {
        version: 1,
        slug,
        name: slug,
        createdAt: nowIso(),
      },
    },
    {
      path: '.project/PROJECT.md',
      kind: 'text',
      content:
        `# ${slug}\n\n` +
        `## Brief\n\n- What is this project?\n- What are the current goals?\n\n` +
        `## Conventions\n\n- Language/runtime:\n- Build/test:\n- Lint/format:\n\n` +
        `## Key links\n\n- Docs: \n- Repo: \n\n`,
    },
    {
      path: '.project/targets.json',
      kind: 'json',
      content: {
        version: 1,
        targets: {
          context: {
            description: 'Print project context (PROJECT.md) and target list',
            commands: [],
          },
          build: { description: 'Build the project (fill in)', commands: [] },
          test: { description: 'Run tests (fill in)', commands: [] },
          lint: { description: 'Run lint checks (fill in)', commands: [] },
          format: { description: 'Auto-format (fill in)', commands: [] },
          run: { description: 'Run the app (fill in)', commands: [] },
          dev: { description: 'Dev mode/watchers (fill in)', commands: [] },
          deploy: { description: 'Deploy workflow (fill in)', commands: [] },
          doctor: { description: 'Project health checks (fill in)', commands: [] },
          artifacts: { description: 'List artifacts', commands: [] },
        },
      },
    },
    {
      path: '.project/index/artifacts.json',
      kind: 'json',
      content: { version: 1, artifacts: [] },
    },
  ];

  return {
    id: planId,
    kind: 'init',
    createdAt: nowIso(),
    root,
    writes: files,
  };
}

function savePlan(root, plan) {
  const projDir = path.join(root, '.project');
  const p = path.join(projDir, 'history', 'plans', `${plan.id}.json`);
  writeJson(p, plan);
  return p;
}

function applyPlan(root, planId) {
  const projDir = path.join(root, '.project');
  const planPath = path.join(projDir, 'history', 'plans', `${planId}.json`);
  if (!exists(planPath)) die(`Plan not found: ${planId}`);
  const plan = readJson(planPath);

  const applied = [];
  for (const w of plan.writes || []) {
    const dst = path.join(root, w.path);
    if (w.kind === 'json') {
      writeJson(dst, w.content);
    } else {
      fs.mkdirSync(path.dirname(dst), { recursive: true });
      fs.writeFileSync(dst, String(w.content));
    }
    applied.push({ path: w.path, kind: w.kind });
  }

  const receipt = {
    id: `apply-${Date.now()}`,
    planId,
    appliedAt: nowIso(),
    root,
    writes: applied,
  };

  const receiptPath = path.join(projDir, 'history', 'applies', `${receipt.id}.json`);
  writeJson(receiptPath, receipt);

  return { receipt, receiptPath };
}

function addArtifactPlan(root, spec, tags) {
  const planId = `plan-${Date.now()}`;
  const projDir = ensureBundle(root);

  const artifactsPath = path.join(projDir, 'index', 'artifacts.json');
  const idx = exists(artifactsPath) ? readJson(artifactsPath) : { version: 1, artifacts: [] };

  const artifact = {
    id: `artifact-${Date.now()}`,
    createdAt: nowIso(),
    type: /^https?:\/\//.test(spec) ? 'url' : 'file',
    spec,
    tags: tags || [],
  };

  // plan writes the updated index
  const next = { ...idx, artifacts: [...(idx.artifacts || []), artifact] };

  return {
    id: planId,
    kind: 'artifact.add',
    createdAt: nowIso(),
    root,
    writes: [
      {
        path: '.project/index/artifacts.json',
        kind: 'json',
        content: next,
      },
    ],
  };
}

function printContext(root) {
  const projDir = ensureBundle(root);
  const md = path.join(projDir, 'PROJECT.md');
  const pj = path.join(projDir, 'project.json');
  const targets = loadTargets(root);

  const meta = exists(pj) ? readJson(pj) : null;
  const brief = exists(md) ? fs.readFileSync(md, 'utf8') : '';

  process.stdout.write(`# Project: ${meta?.name || path.basename(root)}\n`);
  process.stdout.write(`Root: ${root}\n\n`);
  if (brief) process.stdout.write(brief.trimEnd() + '\n\n');

  const names = Object.keys(targets.targets || {}).sort();
  process.stdout.write('## Targets\n');
  for (const n of names) {
    const d = targets.targets[n]?.description || '';
    process.stdout.write(`- ${n}${d ? ` — ${d}` : ''}\n`);
  }
}

function runTarget(root, targetName) {
  const targets = loadTargets(root);
  const t = (targets.targets || {})[targetName];
  if (!t) die(`Unknown target: ${targetName}`);

  // built-in targets
  if (targetName === 'context') {
    printContext(root);
    return { code: 0 };
  }
  if (targetName === 'artifacts') {
    const p = path.join(ensureBundle(root), 'index', 'artifacts.json');
    const idx = exists(p) ? readJson(p) : { version: 1, artifacts: [] };
    process.stdout.write(JSON.stringify(idx, null, 2) + '\n');
    return { code: 0 };
  }

  const commands = t.commands || [];
  if (!Array.isArray(commands) || commands.length === 0) {
    die(`Target '${targetName}' has no commands. Define it in .project/targets.json`);
  }

  const results = [];
  for (const cmd of commands) {
    const r = spawnSync(cmd, {
      cwd: root,
      shell: true,
      stdio: 'inherit',
      env: process.env,
    });
    results.push({ cmd, status: r.status });
    if (r.status !== 0) return { code: r.status ?? 1, results };
  }
  return { code: 0, results };
}

function keywordSearch(root, term) {
  // v1: grep within .project + a few common docs if present
  const projDir = ensureBundle(root);
  const candidates = [
    projDir,
    path.join(root, 'README.md'),
    path.join(root, 'docs'),
  ].filter((p) => exists(p));

  const hits = [];
  const esc = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const re = new RegExp(esc, 'i');

  function walk(p) {
    const st = fs.statSync(p);
    if (st.isDirectory()) {
      for (const ent of fs.readdirSync(p)) {
        if (ent === 'node_modules' || ent === '.git') continue;
        walk(path.join(p, ent));
      }
      return;
    }
    if (st.size > 2_000_000) return; // skip huge
    let txt;
    try {
      txt = fs.readFileSync(p, 'utf8');
    } catch {
      return;
    }
    const lines = txt.split(/\r?\n/);
    for (let i = 0; i < lines.length; i++) {
      if (re.test(lines[i])) hits.push({ file: path.relative(root, p), line: i + 1, text: lines[i].slice(0, 300) });
    }
  }

  for (const c of candidates) walk(c);
  process.stdout.write(JSON.stringify({ term, hits }, null, 2) + '\n');
}

// --- arg parsing ---
const argv = process.argv.slice(2);
if (argv.length === 0) die('usage: project <verb> ...');

const verb = argv[0];

if (verb === 'detect') {
  const root = findProjectRoot(process.cwd());
  if (!root) die('No .project bundle found in parent directories', 2);
  process.stdout.write(JSON.stringify({ root, projectDir: path.join(root, '.project') }, null, 2) + '\n');
  process.exit(0);
}

if (verb === 'context') {
  const root = findProjectRoot(process.cwd());
  if (!root) die('No .project bundle found in parent directories', 2);
  printContext(root);
  process.exit(0);
}

if (verb === 'target') {
  const sub = argv[1];
  const root = findProjectRoot(process.cwd());
  if (!root) die('No .project bundle found in parent directories', 2);

  if (sub === 'list') {
    const targets = loadTargets(root);
    process.stdout.write(JSON.stringify({ root, targets: targets.targets || {} }, null, 2) + '\n');
    process.exit(0);
  }

  if (sub === 'run') {
    const t = argv[2];
    if (!t) die('usage: project target run <target>');
    const r = runTarget(root, t);
    process.exit(r.code);
  }

  die('usage: project target <list|run> ...');
}

if (verb === 'init') {
  const root = process.cwd();
  const plan = defaultInitPlan(root);
  const planPath = savePlan(root, plan);
  process.stdout.write(JSON.stringify({ plan, planPath }, null, 2) + '\n');
  process.exit(0);
}

if (verb === 'apply') {
  const root = findProjectRoot(process.cwd()) || process.cwd();
  const planId = argv[1];
  if (!planId) die('usage: project apply <planId>');
  // if no bundle yet, apply assumes CWD is the root
  const r = applyPlan(root, planId);
  process.stdout.write(JSON.stringify(r, null, 2) + '\n');
  process.exit(0);
}

if (verb === 'artifact') {
  const sub = argv[1];
  if (sub !== 'add') die('usage: project artifact add <path|url> [--tags a,b,c]');

  const spec = argv[2];
  if (!spec) die('usage: project artifact add <path|url> [--tags a,b,c]');

  let tags = [];
  const ti = argv.indexOf('--tags');
  if (ti !== -1) tags = String(argv[ti + 1] || '').split(',').map((s) => s.trim()).filter(Boolean);

  const root = findProjectRoot(process.cwd());
  if (!root) die('No .project bundle found in parent directories (run: project init)', 2);

  const plan = addArtifactPlan(root, spec, tags);
  const planPath = savePlan(root, plan);
  process.stdout.write(JSON.stringify({ plan, planPath }, null, 2) + '\n');
  process.exit(0);
}

if (verb === 'search') {
  const sub = argv[1];
  if (sub !== 'keyword') die('usage: project search keyword <term>');
  const term = argv.slice(2).join(' ').trim();
  if (!term) die('usage: project search keyword <term>');
  const root = findProjectRoot(process.cwd());
  if (!root) die('No .project bundle found in parent directories', 2);
  keywordSearch(root, term);
  process.exit(0);
}

die(`Unknown verb: ${verb}`);


path: scripts/server.js
content: #!/usr/bin/env node
/**
 * mcp-project-router (v1)
 *
 * MCP server that wraps the `project` CLI.
 *
 * Tools:
 * - project_detect
 * - project_context_read
 * - project_target_list
 * - project_target_run
 * - project_workspace_init_plan
 * - project_plan_apply
 * - project_artifact_add_plan
 * - project_search_keyword
 */

const { spawnSync } = require('child_process');
const fs = require('fs');

const PROJECT_BIN = '/home/safa/clawd/bin/project';

let buf = Buffer.alloc(0);
let framing = null; // 'lsp' | 'ndjson'

function send(obj) {
  const json = JSON.stringify(obj);
  if (framing === 'ndjson') {
    process.stdout.write(json + '\n');
    return;
  }
  const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
  process.stdout.write(header);
  process.stdout.write(json);
}

function tryParseNdjson() {
  const s = buf.toString('utf8');
  const idx = s.indexOf('\n');
  if (idx === -1) return false;
  const line = s.slice(0, idx).trim();
  if (!line.startsWith('{')) return false;
  try {
    const msg = JSON.parse(line);
    buf = Buffer.from(s.slice(idx + 1), 'utf8');
    framing = framing || 'ndjson';
    handle(msg);
    return true;
  } catch {
    return false;
  }
}

function parseMessages() {
  while (true) {
    if (framing === 'ndjson') {
      if (!tryParseNdjson()) return;
      continue;
    }

    let sep = '\r\n\r\n';
    let headerEnd = buf.indexOf(sep);
    if (headerEnd === -1) {
      sep = '\n\n';
      headerEnd = buf.indexOf(sep);
    }

    if (headerEnd === -1) {
      if (tryParseNdjson()) continue;
      return;
    }

    const header = buf.slice(0, headerEnd).toString('utf8');
    const m = header.match(/Content-Length:\s*(\d+)/i);
    const sepLen = Buffer.byteLength(sep);
    if (!m) {
      buf = buf.slice(headerEnd + sepLen);
      continue;
    }

    framing = framing || 'lsp';

    const len = parseInt(m[1], 10);
    const total = headerEnd + sepLen + len;
    if (buf.length < total) return;

    const body = buf.slice(headerEnd + sepLen, total).toString('utf8');
    buf = buf.slice(total);

    let msg;
    try {
      msg = JSON.parse(body);
    } catch {
      continue;
    }
    handle(msg);
  }
}

function okResult(content) {
  return { content: [{ type: 'text', text: typeof content === 'string' ? content : JSON.stringify(content, null, 2) }] };
}

function toolSchemas() {
  return [
    {
      name: 'project_detect',
      description: 'Detect current project root by searching for a .project bundle in parent directories.',
      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
    },
    {
      name: 'project_context_read',
      description: 'Read the current project context (PROJECT.md + target list).',
      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
    },
    {
      name: 'project_target_list',
      description: 'List targets for the current project.',
      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
    },
    {
      name: 'project_target_run',
      description: 'Run a target for the current project. Executes commands from .project/targets.json.',
      inputSchema: {
        type: 'object',
        properties: {
          target: { type: 'string' },
        },
        required: ['target'],
        additionalProperties: false,
      },
    },
    {
      name: 'project_workspace_init_plan',
      description: 'Create an init plan in the current directory (does not write until apply).',
      inputSchema: { type: 'object', properties: {}, additionalProperties: false },
    },
    {
      name: 'project_plan_apply',
      description: 'Apply a previously created plan by planId.',
      inputSchema: {
        type: 'object',
        properties: { planId: { type: 'string' } },
        required: ['planId'],
        additionalProperties: false,
      },
    },
    {
      name: 'project_artifact_add_plan',
      description: 'Create a plan to add an artifact (path or url) to .project/index/artifacts.json.',
      inputSchema: {
        type: 'object',
        properties: {
          spec: { type: 'string' },
          tags: { type: 'array', items: { type: 'string' }, default: [] },
        },
        required: ['spec'],
        additionalProperties: false,
      },
    },
    {
      name: 'project_search_keyword',
      description: 'Keyword search within .project and common docs.',
      inputSchema: {
        type: 'object',
        properties: { term: { type: 'string' } },
        required: ['term'],
        additionalProperties: false,
      },
    },
  ];
}

function runProject(args) {
  if (!fs.existsSync(PROJECT_BIN)) {
    throw new Error(`project CLI not found at ${PROJECT_BIN}`);
  }
  const r = spawnSync(PROJECT_BIN, args, { encoding: 'utf8' });
  const out = (r.stdout || '') + (r.stderr || '');
  return { code: r.status ?? 1, out };
}

function handle(msg) {
  const { id, method, params } = msg;
  if (!method) return;

  function reply(result) {
    if (id === undefined || id === null) return;
    send({ jsonrpc: '2.0', id, result });
  }

  function error(code, message) {
    if (id === undefined || id === null) return;
    send({ jsonrpc: '2.0', id, error: { code, message } });
  }

  try {
    if (method === 'initialize') {
      const pv = params?.protocolVersion || '2024-11-05';
      reply({ protocolVersion: pv, serverInfo: { name: 'mcp-project-router', version: '0.1.0' }, capabilities: { tools: {} } });
      return;
    }

    if (method === 'tools/list') {
      reply({ tools: toolSchemas() });
      return;
    }

    if (method === 'tools/call') {
      const name = params?.name;
      const args = params?.arguments || {};

      if (name === 'project_detect') {
        const r = runProject(['detect']);
        if (r.code !== 0) throw new Error(r.out.trim() || 'detect failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_context_read') {
        const r = runProject(['context']);
        if (r.code !== 0) throw new Error(r.out.trim() || 'context failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_target_list') {
        const r = runProject(['target', 'list']);
        if (r.code !== 0) throw new Error(r.out.trim() || 'target list failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_target_run') {
        const r = runProject(['target', 'run', String(args.target)]);
        // pass-through output; allow nonzero to show failure logs
        reply(okResult({ exitCode: r.code, output: r.out }));
        return;
      }

      if (name === 'project_workspace_init_plan') {
        const r = runProject(['init']);
        if (r.code !== 0) throw new Error(r.out.trim() || 'init failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_plan_apply') {
        const r = runProject(['apply', String(args.planId)]);
        if (r.code !== 0) throw new Error(r.out.trim() || 'apply failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_artifact_add_plan') {
        const spec = String(args.spec);
        const tags = Array.isArray(args.tags) ? args.tags : [];
        const r = runProject(['artifact', 'add', spec, '--tags', tags.join(',')]);
        if (r.code !== 0) throw new Error(r.out.trim() || 'artifact add failed');
        reply(okResult(r.out));
        return;
      }

      if (name === 'project_search_keyword') {
        const r = runProject(['search', 'keyword', String(args.term)]);
        if (r.code !== 0) throw new Error(r.out.trim() || 'search failed');
        reply(okResult(r.out));
        return;
      }

      error(-32601, `Unknown tool: ${name}`);
      return;
    }

    if (method === 'initialized') return;

    error(-32601, `Unknown method: ${method}`);
  } catch (e) {
    error(-32000, String(e && e.message ? e.message : e));
  }
}

process.stdin.on('data', (chunk) => {
  buf = Buffer.concat([buf, chunk]);
  parseMessages();
});

process.stdin.on('end', () => process.exit(0));


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)