@groundctl/cli 0.1.1 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +221 -21
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -736,14 +736,40 @@ function timeSince(isoDate) {
736
736
  // src/commands/claim.ts
737
737
  import chalk3 from "chalk";
738
738
  import { randomUUID } from "crypto";
739
- async function claimCommand(featureIdOrName, options) {
740
- const db = await openDb();
741
- const feature = queryOne(
739
+ function findFeature(db, term) {
740
+ return queryOne(
742
741
  db,
743
742
  `SELECT id, name, status FROM features
744
- WHERE id = ? OR name = ? OR name LIKE ?`,
745
- [featureIdOrName, featureIdOrName, `%${featureIdOrName}%`]
743
+ WHERE id = ? OR name = ?
744
+ UNION ALL
745
+ SELECT id, name, status FROM features
746
+ WHERE (id LIKE ? OR name LIKE ?)
747
+ AND id != ? AND name != ?
748
+ UNION ALL
749
+ SELECT id, name, status FROM features
750
+ WHERE (id LIKE ? OR name LIKE ?)
751
+ AND id NOT LIKE ? AND name NOT LIKE ?
752
+ AND id != ? AND name != ?
753
+ LIMIT 1`,
754
+ [
755
+ term,
756
+ term,
757
+ `${term}%`,
758
+ `${term}%`,
759
+ term,
760
+ term,
761
+ `%${term}%`,
762
+ `%${term}%`,
763
+ `${term}%`,
764
+ `${term}%`,
765
+ term,
766
+ term
767
+ ]
746
768
  );
769
+ }
770
+ async function claimCommand(featureIdOrName, options) {
771
+ const db = await openDb();
772
+ const feature = findFeature(db, featureIdOrName);
747
773
  if (!feature) {
748
774
  console.log(chalk3.red(`
749
775
  Feature "${featureIdOrName}" not found.
@@ -821,12 +847,7 @@ async function claimCommand(featureIdOrName, options) {
821
847
  }
822
848
  async function completeCommand(featureIdOrName) {
823
849
  const db = await openDb();
824
- const feature = queryOne(
825
- db,
826
- `SELECT id, name, status FROM features
827
- WHERE id = ? OR name = ? OR name LIKE ?`,
828
- [featureIdOrName, featureIdOrName, `%${featureIdOrName}%`]
829
- );
850
+ const feature = findFeature(db, featureIdOrName);
830
851
  if (!feature) {
831
852
  console.log(chalk3.red(`
832
853
  Feature "${featureIdOrName}" not found.
@@ -1097,10 +1118,10 @@ function parseTranscript(transcriptPath, sessionId, projectPath) {
1097
1118
  if (filePath && isFilePath(filePath)) {
1098
1119
  const content2 = tool.input.content;
1099
1120
  const lines2 = content2 ? countContentLines(content2) : 0;
1100
- const rel = relativePath(filePath, projectPath);
1101
- if (!filesMap.has(rel)) {
1102
- filesMap.set(rel, {
1103
- path: rel,
1121
+ const rel2 = relativePath(filePath, projectPath);
1122
+ if (!filesMap.has(rel2)) {
1123
+ filesMap.set(rel2, {
1124
+ path: rel2,
1104
1125
  operation: "created",
1105
1126
  linesChanged: lines2
1106
1127
  });
@@ -1110,8 +1131,8 @@ function parseTranscript(transcriptPath, sessionId, projectPath) {
1110
1131
  if (tool.name === "Edit" && succeeded) {
1111
1132
  const filePath = tool.input.file_path;
1112
1133
  if (filePath && isFilePath(filePath)) {
1113
- const rel = relativePath(filePath, projectPath);
1114
- const existing = filesMap.get(rel);
1134
+ const rel2 = relativePath(filePath, projectPath);
1135
+ const existing = filesMap.get(rel2);
1115
1136
  const oldStr = tool.input.old_string;
1116
1137
  const newStr = tool.input.new_string;
1117
1138
  const lines2 = Math.max(
@@ -1121,8 +1142,8 @@ function parseTranscript(transcriptPath, sessionId, projectPath) {
1121
1142
  if (existing) {
1122
1143
  existing.linesChanged += lines2;
1123
1144
  } else {
1124
- filesMap.set(rel, {
1125
- path: rel,
1145
+ filesMap.set(rel2, {
1146
+ path: rel2,
1126
1147
  operation: "modified",
1127
1148
  linesChanged: lines2
1128
1149
  });
@@ -1192,8 +1213,8 @@ function extractBashFileOps(command, filesMap, projectPath) {
1192
1213
  const rmMatches = command.matchAll(/\brm\s+(?:-[rf]+\s+)?([^\s;&|]+\.[a-zA-Z0-9]+)/g);
1193
1214
  for (const m of rmMatches) {
1194
1215
  if (isFilePath(m[1])) {
1195
- const rel = relativePath(m[1], projectPath);
1196
- filesMap.set(rel, { path: rel, operation: "deleted", linesChanged: 0 });
1216
+ const rel2 = relativePath(m[1], projectPath);
1217
+ filesMap.set(rel2, { path: rel2, operation: "deleted", linesChanged: 0 });
1197
1218
  }
1198
1219
  }
1199
1220
  }
@@ -1646,6 +1667,184 @@ async function healthCommand() {
1646
1667
  }
1647
1668
  }
1648
1669
 
1670
+ // src/commands/dashboard.ts
1671
+ import { createServer } from "http";
1672
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
1673
+ import { join as join7, dirname as dirname2 } from "path";
1674
+ import { exec } from "child_process";
1675
+ import chalk11 from "chalk";
1676
+ import initSqlJs2 from "sql.js";
1677
+ function findDbPath(startDir = process.cwd()) {
1678
+ let dir = startDir;
1679
+ for (let i = 0; i < 10; i++) {
1680
+ const candidate = join7(dir, ".groundctl", "db.sqlite");
1681
+ if (existsSync5(candidate)) return candidate;
1682
+ const parent = dirname2(dir);
1683
+ if (parent === dir) break;
1684
+ dir = parent;
1685
+ }
1686
+ return null;
1687
+ }
1688
+ async function readDb(dbPath) {
1689
+ const SQL = await initSqlJs2();
1690
+ const buffer = readFileSync5(dbPath);
1691
+ const db = new SQL.Database(buffer);
1692
+ function q(sql, params = []) {
1693
+ const stmt = db.prepare(sql);
1694
+ stmt.bind(params);
1695
+ const rows = [];
1696
+ while (stmt.step()) rows.push(stmt.getAsObject());
1697
+ stmt.free();
1698
+ return rows;
1699
+ }
1700
+ const features = q("SELECT * FROM features ORDER BY CASE status WHEN 'in_progress' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END, CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END");
1701
+ const sessions = q("SELECT * FROM sessions ORDER BY started_at DESC LIMIT 20");
1702
+ const claims = q("SELECT c.*, f.name as feature_name FROM claims c JOIN features f ON c.feature_id = f.id WHERE c.released_at IS NULL");
1703
+ const decisions = q("SELECT d.*, s.id as sess FROM decisions d JOIN sessions s ON d.session_id = s.id ORDER BY d.id DESC LIMIT 30");
1704
+ const files = q("SELECT * FROM files_modified ORDER BY id DESC LIMIT 100");
1705
+ const total = features.length;
1706
+ const done = features.filter((f) => f.status === "done").length;
1707
+ const pct = total > 0 ? Math.round(done / total * 100) : 0;
1708
+ const testFiles = files.filter((f) => /\.(test|spec)\./.test(f.path) || f.path.includes("__tests__")).length;
1709
+ const decCount = decisions.length;
1710
+ const stale = claims.filter((c) => Date.now() - new Date(c.claimed_at).getTime() > 864e5).length;
1711
+ const health = Math.min(100, Math.round(
1712
+ done / Math.max(1, total) * 40 + (testFiles > 0 ? Math.min(20, testFiles * 5) : 0) + (decCount > 0 ? Math.min(20, decCount * 2) : 0) + (stale === 0 ? 10 : 0)
1713
+ ));
1714
+ db.close();
1715
+ return { features, sessions, claims, decisions, files, meta: { total, done, pct, health, testFiles, decCount, stale } };
1716
+ }
1717
+ function bar(done, total, w = 20) {
1718
+ const n = total > 0 ? Math.round(done / total * w) : 0;
1719
+ return "\u2588".repeat(n) + "\u2591".repeat(w - n);
1720
+ }
1721
+ function esc(s) {
1722
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1723
+ }
1724
+ function rel(ts) {
1725
+ if (!ts) return "\u2014";
1726
+ const m = Math.floor((Date.now() - new Date(ts).getTime()) / 6e4);
1727
+ if (m < 1) return "just now";
1728
+ if (m < 60) return `${m}m ago`;
1729
+ const h = Math.floor(m / 60);
1730
+ if (h < 24) return `${h}h ago`;
1731
+ return `${Math.floor(h / 24)}d ago`;
1732
+ }
1733
+ function renderHtml(data, projectName, dbPath) {
1734
+ const { features, sessions, claims, decisions, files, meta } = data;
1735
+ const sc = meta.pct >= 70 ? "#4ade80" : meta.pct >= 40 ? "#facc15" : "#f87171";
1736
+ const hc = meta.health >= 70 ? "#4ade80" : meta.health >= 40 ? "#facc15" : "#f87171";
1737
+ const claimRows = claims.length ? claims.map((c) => `<div class="claim-row"><span class="cy">\u25CF</span><span class="cn">${esc(c.feature_name)}</span><span class="cd">session ${esc(c.session_id)}</span><span class="cb">${rel(c.claimed_at)}</span></div>`).join("") : `<div class="empty">No active claims</div>`;
1738
+ const featRows = features.map((f) => {
1739
+ const cls = f.status === "done" ? "done" : f.status === "in_progress" ? "active" : "pend";
1740
+ const icon = f.status === "done" ? "\u2713" : f.status === "in_progress" ? "\u25CF" : "\u25CB";
1741
+ return `<div class="fr ${cls}"><span class="fi">${icon}</span><span class="fn">${esc(f.name)}</span><span class="fp p-${esc(f.priority)}">${esc(f.priority)}</span><span class="fs">${esc(f.status)}</span></div>`;
1742
+ }).join("");
1743
+ const sessRows = sessions.length ? sessions.map((s) => {
1744
+ const fc = files.filter((f) => f.session_id === s.id).length;
1745
+ const dc = decisions.filter((d) => d.session_id === s.id).length;
1746
+ return `<div class="sr"><span class="si">${esc(s.id)}</span><span class="sd">${s.ended_at ? "\u25CF" : "\u25CC"}</span><span class="ss">${esc((s.summary ?? "").slice(0, 80))}</span><span class="sm">${fc} files \xB7 ${dc} dec \xB7 ${rel(s.started_at)}</span></div>`;
1747
+ }).join("") : `<div class="empty">No sessions yet</div>`;
1748
+ const decRows = decisions.slice(0, 10).length ? decisions.slice(0, 10).map((d) => `<div class="dr"><span class="di">${esc(d.sess)}</span><span class="dt">${esc(d.description.slice(0, 100))}</span>${d.rationale ? `<span class="dra">${esc(d.rationale.slice(0, 80))}</span>` : ""}</div>`).join("") : `<div class="empty">No decisions documented yet</div>`;
1749
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1750
+ <title>${esc(projectName)} \u2014 groundctl</title><meta http-equiv="refresh" content="10">
1751
+ <style>
1752
+ :root{--bg:#0a0a0a;--b2:#111;--b3:#1a1a1a;--br:#222;--tx:#e0e0e0;--dm:#666;--md:#999;--gn:#4ade80;--yw:#facc15;--bl:#60a5fa;--rd:#f87171;--mo:"Berkeley Mono","IBM Plex Mono","Fira Code",ui-monospace,monospace}
1753
+ *{box-sizing:border-box;margin:0;padding:0}
1754
+ body{background:var(--bg);color:var(--tx);font-family:var(--mo);font-size:13px;line-height:1.6}
1755
+ .layout{display:grid;grid-template-columns:280px 1fr;min-height:100vh}
1756
+ .sidebar{border-right:1px solid var(--br);padding:24px 20px;display:flex;flex-direction:column;gap:28px}
1757
+ .main{padding:24px;display:flex;flex-direction:column;gap:20px;overflow:auto}
1758
+ .pn{font-size:1.1rem;font-weight:700;color:#fff;margin-bottom:4px}
1759
+ .pc{color:${sc};font-size:.9rem;margin-bottom:8px}
1760
+ .pb{font-size:.85rem;color:var(--gn);letter-spacing:.5px;margin-bottom:4px}
1761
+ .ps{font-size:.75rem;color:var(--dm)}
1762
+ .hl{font-size:.7rem;color:var(--dm);text-transform:uppercase;letter-spacing:.08em}
1763
+ .hs{font-size:1.8rem;font-weight:700;color:${hc}}
1764
+ .hi{margin-top:10px;display:flex;flex-direction:column;gap:4px}
1765
+ .hi>div{font-size:.8rem;color:var(--md);display:flex;gap:8px}
1766
+ .ok{color:var(--gn)}.warn{color:var(--yw)}.bad{color:var(--rd)}
1767
+ .sec{background:var(--b2);border:1px solid var(--br);border-radius:8px;overflow:hidden}
1768
+ .sh{padding:10px 16px;border-bottom:1px solid var(--br);font-size:.7rem;text-transform:uppercase;letter-spacing:.1em;color:var(--dm);display:flex;justify-content:space-between}
1769
+ .sc{background:var(--b3);border-radius:4px;padding:1px 6px;font-size:.7rem;color:var(--md)}
1770
+ .claim-row{display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--br)}
1771
+ .claim-row:last-child{border-bottom:none}
1772
+ .cy{color:var(--yw)}.cn{flex:1;color:#fff}.cd{color:var(--dm);font-size:.8rem}.cb{color:var(--bl);font-size:.8rem}
1773
+ .fr{display:grid;grid-template-columns:20px 1fr 70px 90px;gap:8px;padding:8px 16px;border-bottom:1px solid var(--br);align-items:center}
1774
+ .fr:last-child{border-bottom:none}
1775
+ .done{opacity:.45}.active{background:rgba(250,204,21,.04)}
1776
+ .done .fi{color:var(--gn)}.active .fi{color:var(--yw)}.pend .fi{color:var(--dm)}
1777
+ .fn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1778
+ .fp{font-size:.75rem;text-align:right}.fs{font-size:.75rem;color:var(--dm);text-align:right}
1779
+ .p-critical{color:var(--rd)}.p-high{color:var(--yw)}.p-medium{color:var(--md)}.p-low{color:var(--dm)}
1780
+ .sr{display:grid;grid-template-columns:60px 16px 1fr auto;gap:8px;padding:10px 16px;border-bottom:1px solid var(--br);align-items:start}
1781
+ .sr:last-child{border-bottom:none}
1782
+ .si{color:var(--bl);font-weight:600;font-size:.85rem}.sd{color:var(--gn)}.ss{color:var(--md);font-size:.8rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sm{color:var(--dm);font-size:.75rem;white-space:nowrap;text-align:right}
1783
+ .dr{padding:10px 16px;border-bottom:1px solid var(--br);display:grid;grid-template-columns:50px 1fr;gap:8px}
1784
+ .dr:last-child{border-bottom:none}
1785
+ .di{color:var(--bl);font-size:.8rem}.dt{color:var(--tx);font-size:.8rem}.dra{grid-column:2;font-size:.75rem;color:var(--dm);font-style:italic}
1786
+ .empty{padding:16px;color:var(--dm);font-size:.85rem;text-align:center}
1787
+ .rn{font-size:.7rem;color:var(--dm);text-align:center;padding-top:8px}
1788
+ .top{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1789
+ @media(max-width:800px){.layout{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid var(--br)}.top{grid-template-columns:1fr}}
1790
+ </style></head><body>
1791
+ <div class="layout">
1792
+ <aside class="sidebar">
1793
+ <div><div class="pn">${esc(projectName)}</div><div class="pc">${meta.pct}% implemented</div><div class="pb">${bar(meta.done, meta.total)}</div><div class="ps">${meta.done}/${meta.total} features done</div></div>
1794
+ <div><div class="hl">Health Score</div><div class="hs">${meta.health}<span style="font-size:1rem;color:var(--dm)">/100</span></div>
1795
+ <div class="hi">
1796
+ <div><span class="${meta.done > 0 ? "ok" : "warn"}">${meta.done > 0 ? "\u2713" : "\u26A0"}</span><span>Features ${meta.done}/${meta.total}</span></div>
1797
+ <div><span class="${meta.testFiles > 0 ? "ok" : "bad"}">${meta.testFiles > 0 ? "\u2713" : "\u2717"}</span><span>Tests ${meta.testFiles} files</span></div>
1798
+ <div><span class="${meta.decCount > 0 ? "ok" : "warn"}">${meta.decCount > 0 ? "\u2713" : "\u26A0"}</span><span>Decisions ${meta.decCount}</span></div>
1799
+ <div><span class="${meta.stale === 0 ? "ok" : "bad"}">${meta.stale === 0 ? "\u2713" : "\u2717"}</span><span>Claims ${meta.stale > 0 ? meta.stale + " stale" : "healthy"}</span></div>
1800
+ </div></div>
1801
+ <div class="rn">auto-refresh 10s<br><span style="color:var(--br)">${esc(dbPath.split("/").slice(-3).join("/"))}</span></div>
1802
+ </aside>
1803
+ <main class="main">
1804
+ <div class="sec"><div class="sh"><span>Claims live</span><span class="sc">${claims.length}</span></div>${claimRows}</div>
1805
+ <div class="top">
1806
+ <div class="sec"><div class="sh"><span>Features</span><span class="sc">${features.length}</span></div>${featRows}</div>
1807
+ <div class="sec"><div class="sh"><span>Session timeline</span><span class="sc">${sessions.length}</span></div>${sessRows}</div>
1808
+ </div>
1809
+ <div class="sec"><div class="sh"><span>Recent decisions</span><span class="sc">${decisions.length}</span></div>${decRows}</div>
1810
+ </main>
1811
+ </div></body></html>`;
1812
+ }
1813
+ async function dashboardCommand(options) {
1814
+ const port = parseInt(options.port ?? "4242");
1815
+ const server = createServer(async (req, res) => {
1816
+ if (req.url !== "/" && req.url !== "") {
1817
+ res.writeHead(404);
1818
+ res.end("Not found");
1819
+ return;
1820
+ }
1821
+ const dbPath = findDbPath();
1822
+ if (!dbPath) {
1823
+ res.writeHead(200, { "Content-Type": "text/html" });
1824
+ res.end(`<!DOCTYPE html><html><body style="background:#0a0a0a;color:#e0e0e0;font-family:monospace;padding:40px"><h2>groundctl</h2><p style="color:#f87171">No .groundctl/db.sqlite found.</p><p>Run: <code>groundctl init</code></p></body></html>`);
1825
+ return;
1826
+ }
1827
+ try {
1828
+ const data = await readDb(dbPath);
1829
+ const name = process.cwd().split("/").pop() ?? "project";
1830
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1831
+ res.end(renderHtml(data, name, dbPath));
1832
+ } catch (err) {
1833
+ res.writeHead(500, { "Content-Type": "text/plain" });
1834
+ res.end(`Error: ${err.message}`);
1835
+ }
1836
+ });
1837
+ server.listen(port, "127.0.0.1", () => {
1838
+ console.log(chalk11.bold(`
1839
+ groundctl dashboard \u2192 `) + chalk11.blue(`http://localhost:${port}`) + "\n");
1840
+ console.log(chalk11.gray(" Auto-refreshes every 10s. Press Ctrl+C to stop.\n"));
1841
+ exec(`open http://localhost:${port} 2>/dev/null || xdg-open http://localhost:${port} 2>/dev/null || true`);
1842
+ });
1843
+ await new Promise((_, reject) => {
1844
+ server.on("error", reject);
1845
+ });
1846
+ }
1847
+
1649
1848
  // src/index.ts
1650
1849
  var require2 = createRequire(import.meta.url);
1651
1850
  var pkg = require2("../package.json");
@@ -1670,4 +1869,5 @@ program.command("ingest").description("Parse a transcript and write session data
1670
1869
  );
1671
1870
  program.command("report").description("Generate SESSION_REPORT.md from SQLite").option("-s, --session <id>", "Report for a specific session").option("--all", "Generate report for all sessions").action(reportCommand);
1672
1871
  program.command("health").description("Show product health score").action(healthCommand);
1872
+ program.command("dashboard").description("Start web dashboard on port 4242").option("-p, --port <port>", "Port number", "4242").action(dashboardCommand);
1673
1873
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundctl/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Product memory for AI agent builders",
5
5
  "license": "MIT",
6
6
  "bin": {