@groundctl/cli 0.2.1 → 0.3.0

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 +264 -11
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -274,7 +274,7 @@ function generateProjectState(db, projectName) {
274
274
  md += "\n";
275
275
  }
276
276
  if (decisions.length > 0) {
277
- md += "## Decisions made\n";
277
+ md += "## Architecture log\n";
278
278
  for (const d of decisions) {
279
279
  md += `- ${d.session_id}: ${d.description}`;
280
280
  if (d.rationale) md += ` \u2014 ${d.rationale}`;
@@ -874,7 +874,7 @@ async function completeCommand(featureIdOrName) {
874
874
  import { writeFileSync as writeFileSync3 } from "fs";
875
875
  import { join as join4 } from "path";
876
876
  import chalk4 from "chalk";
877
- async function syncCommand() {
877
+ async function syncCommand(opts) {
878
878
  const db = await openDb();
879
879
  const projectName = process.cwd().split("/").pop() ?? "unknown";
880
880
  const projectState = generateProjectState(db, projectName);
@@ -883,8 +883,10 @@ async function syncCommand() {
883
883
  const cwd = process.cwd();
884
884
  writeFileSync3(join4(cwd, "PROJECT_STATE.md"), projectState);
885
885
  writeFileSync3(join4(cwd, "AGENTS.md"), agentsMd);
886
- console.log(chalk4.green("\n \u2713 PROJECT_STATE.md regenerated"));
887
- console.log(chalk4.green(" \u2713 AGENTS.md regenerated\n"));
886
+ if (!opts?.silent) {
887
+ console.log(chalk4.green("\n \u2713 PROJECT_STATE.md regenerated"));
888
+ console.log(chalk4.green(" \u2713 AGENTS.md regenerated\n"));
889
+ }
888
890
  }
889
891
 
890
892
  // src/commands/next.ts
@@ -1278,19 +1280,23 @@ function buildSummary(sessionId, fileCount, commitCount, decisionCount, lastText
1278
1280
  }
1279
1281
 
1280
1282
  // src/commands/ingest.ts
1283
+ function claudeEncode(p) {
1284
+ return p.replace(/[^a-zA-Z0-9]/g, "-");
1285
+ }
1281
1286
  function findLatestTranscript(projectPath) {
1282
1287
  const projectsDir = join5(homedir2(), ".claude", "projects");
1283
1288
  if (!existsSync4(projectsDir)) return null;
1284
- const projectKey = projectPath.replace(/\//g, "-");
1285
1289
  let transcriptDir = null;
1290
+ const projectKey = claudeEncode(projectPath);
1286
1291
  const directMatch = join5(projectsDir, projectKey);
1287
1292
  if (existsSync4(directMatch)) {
1288
1293
  transcriptDir = directMatch;
1289
1294
  } else {
1290
1295
  const projectName = projectPath.split("/").pop() ?? "";
1296
+ const encodedName = claudeEncode(projectName);
1291
1297
  const dirs = readdirSync(projectsDir);
1292
1298
  for (const d of dirs) {
1293
- if (d.endsWith(`-${projectName}`) || d.includes(projectName.replace(/\//g, "-"))) {
1299
+ if (d.endsWith(`-${encodedName}`) || d.includes(encodedName)) {
1294
1300
  transcriptDir = join5(projectsDir, d);
1295
1301
  break;
1296
1302
  }
@@ -1444,7 +1450,7 @@ ${sessionRow.summary}
1444
1450
  md += "\n";
1445
1451
  }
1446
1452
  if (decisions.length > 0) {
1447
- md += "## Decisions\n";
1453
+ md += "## Architecture log\n";
1448
1454
  for (const d of decisions) {
1449
1455
  md += `- ${d.description}`;
1450
1456
  if (d.rationale) md += ` \u2014 ${d.rationale}`;
@@ -1569,7 +1575,7 @@ async function reportCommand(options) {
1569
1575
  console.log(chalk9.green(`
1570
1576
  \u2713 SESSION_REPORT.md written (session ${session.id})
1571
1577
  `));
1572
- console.log(chalk9.gray(` ${files.length} files \xB7 ${decisions.length} decisions \xB7 ${completedFeatures.length} features completed`));
1578
+ console.log(chalk9.gray(` ${files.length} files \xB7 ${decisions.length} arch log entries \xB7 ${completedFeatures.length} features completed`));
1573
1579
  console.log("");
1574
1580
  }
1575
1581
 
@@ -1640,7 +1646,7 @@ async function healthCommand() {
1640
1646
  const decMark = decisionCount > 0 ? "\u2705" : "\u26A0\uFE0F ";
1641
1647
  const decColor = decisionCount > 0 ? chalk10.green : chalk10.yellow;
1642
1648
  console.log(
1643
- ` ${decMark} Decisions ${decColor(decisionCount + " documented")}` + chalk10.gray(` +${decisionScore}pts`)
1649
+ ` ${decMark} Arch log ${decColor(decisionCount + " entries")}` + chalk10.gray(` +${decisionScore}pts`)
1644
1650
  );
1645
1651
  const claimMark = staleClaims === 0 ? "\u2705" : "\u26A0\uFE0F ";
1646
1652
  const claimColor = staleClaims === 0 ? chalk10.green : chalk10.red;
@@ -1656,7 +1662,7 @@ async function healthCommand() {
1656
1662
  const recommendations = [];
1657
1663
  if (testFiles === 0) recommendations.push("Write tests before your next feature (0 test files found).");
1658
1664
  if (staleClaims > 0) recommendations.push(`Release ${staleClaims} stale claim(s) with groundctl complete <feature>.`);
1659
- if (decisionCount === 0) recommendations.push("Document decisions during sessions so agents have context.");
1665
+ if (decisionCount === 0) recommendations.push("Log architecture decisions during sessions so agents understand the why.");
1660
1666
  if (featurePct < 0.5 && total > 0) recommendations.push(`${counts.pending} features pending \u2014 run groundctl next to pick one.`);
1661
1667
  if (recommendations.length > 0) {
1662
1668
  console.log(chalk10.bold(" Recommendations:"));
@@ -1795,7 +1801,7 @@ body{background:var(--bg);color:var(--tx);font-family:var(--mo);font-size:13px;l
1795
1801
  <div class="hi">
1796
1802
  <div><span class="${meta.done > 0 ? "ok" : "warn"}">${meta.done > 0 ? "\u2713" : "\u26A0"}</span><span>Features ${meta.done}/${meta.total}</span></div>
1797
1803
  <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>
1804
+ <div><span class="${meta.decCount > 0 ? "ok" : "warn"}">${meta.decCount > 0 ? "\u2713" : "\u26A0"}</span><span>Architecture log ${meta.decCount} entries</span></div>
1799
1805
  <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
1806
  </div></div>
1801
1807
  <div class="rn">auto-refresh 10s<br><span style="color:var(--br)">${esc(dbPath.split("/").slice(-3).join("/"))}</span></div>
@@ -1845,6 +1851,247 @@ async function dashboardCommand(options) {
1845
1851
  });
1846
1852
  }
1847
1853
 
1854
+ // src/commands/watch.ts
1855
+ import {
1856
+ existsSync as existsSync6,
1857
+ readdirSync as readdirSync2,
1858
+ statSync,
1859
+ writeFileSync as writeFileSync5,
1860
+ readFileSync as readFileSync6,
1861
+ mkdirSync as mkdirSync3,
1862
+ watch as fsWatch
1863
+ } from "fs";
1864
+ import { join as join8, resolve as resolve2 } from "path";
1865
+ import { homedir as homedir3 } from "os";
1866
+ import { spawn } from "child_process";
1867
+ import chalk12 from "chalk";
1868
+ var DEBOUNCE_MS = 8e3;
1869
+ var DIR_POLL_MS = 5e3;
1870
+ function claudeEncode2(p) {
1871
+ return p.replace(/[^a-zA-Z0-9]/g, "-");
1872
+ }
1873
+ function findTranscriptDir(projectPath) {
1874
+ const projectsDir = join8(homedir3(), ".claude", "projects");
1875
+ if (!existsSync6(projectsDir)) return null;
1876
+ const projectKey = claudeEncode2(projectPath);
1877
+ const direct = join8(projectsDir, projectKey);
1878
+ if (existsSync6(direct)) return direct;
1879
+ const projectName = projectPath.split("/").pop() ?? "";
1880
+ const encodedName = claudeEncode2(projectName);
1881
+ const dirs = readdirSync2(projectsDir);
1882
+ for (const d of dirs) {
1883
+ if (d.endsWith(`-${encodedName}`) || d.includes(encodedName)) {
1884
+ return join8(projectsDir, d);
1885
+ }
1886
+ }
1887
+ return null;
1888
+ }
1889
+ function fileSize(p) {
1890
+ try {
1891
+ return statSync(p).size;
1892
+ } catch {
1893
+ return 0;
1894
+ }
1895
+ }
1896
+ function writePidFile(groundctlDir, pid) {
1897
+ try {
1898
+ mkdirSync3(groundctlDir, { recursive: true });
1899
+ writeFileSync5(join8(groundctlDir, "watch.pid"), String(pid), "utf8");
1900
+ } catch {
1901
+ }
1902
+ }
1903
+ function readPidFile(groundctlDir) {
1904
+ try {
1905
+ const raw = readFileSync6(join8(groundctlDir, "watch.pid"), "utf8").trim();
1906
+ return parseInt(raw) || null;
1907
+ } catch {
1908
+ return null;
1909
+ }
1910
+ }
1911
+ function processAlive(pid) {
1912
+ try {
1913
+ process.kill(pid, 0);
1914
+ return true;
1915
+ } catch {
1916
+ return false;
1917
+ }
1918
+ }
1919
+ async function runIngest(transcriptPath, projectPath) {
1920
+ const filename = transcriptPath.split("/").slice(-2).join("/");
1921
+ console.log(
1922
+ chalk12.gray(`
1923
+ [${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] `) + chalk12.cyan(`Transcript stable \u2192 ingesting ${filename}`)
1924
+ );
1925
+ try {
1926
+ const parsed = parseTranscript(transcriptPath, "auto", projectPath);
1927
+ const db = await openDb();
1928
+ const sessionId = parsed.sessionId;
1929
+ const exists = queryOne(db, "SELECT id FROM sessions WHERE id = ?", [sessionId]);
1930
+ if (exists) {
1931
+ db.run("UPDATE sessions SET ended_at = ?, summary = ? WHERE id = ?", [
1932
+ parsed.endedAt,
1933
+ parsed.summary,
1934
+ sessionId
1935
+ ]);
1936
+ } else {
1937
+ db.run(
1938
+ "INSERT INTO sessions (id, agent, started_at, ended_at, summary) VALUES (?, ?, ?, ?, ?)",
1939
+ [sessionId, "claude-code", parsed.startedAt, parsed.endedAt, parsed.summary]
1940
+ );
1941
+ }
1942
+ let newFiles = 0;
1943
+ for (const file of parsed.filesModified) {
1944
+ const dup = queryOne(
1945
+ db,
1946
+ "SELECT id FROM files_modified WHERE session_id = ? AND path = ?",
1947
+ [sessionId, file.path]
1948
+ );
1949
+ if (!dup) {
1950
+ db.run(
1951
+ "INSERT INTO files_modified (session_id, path, operation, lines_changed) VALUES (?, ?, ?, ?)",
1952
+ [sessionId, file.path, file.operation, file.linesChanged]
1953
+ );
1954
+ newFiles++;
1955
+ }
1956
+ }
1957
+ let newDecisions = 0;
1958
+ for (const d of parsed.decisions) {
1959
+ const dup = queryOne(
1960
+ db,
1961
+ "SELECT id FROM decisions WHERE session_id = ? AND description = ?",
1962
+ [sessionId, d.description]
1963
+ );
1964
+ if (!dup) {
1965
+ db.run(
1966
+ "INSERT INTO decisions (session_id, description, rationale) VALUES (?, ?, ?)",
1967
+ [sessionId, d.description, d.rationale ?? null]
1968
+ );
1969
+ newDecisions++;
1970
+ }
1971
+ }
1972
+ saveDb();
1973
+ closeDb();
1974
+ const parts = [];
1975
+ if (newFiles > 0) parts.push(`${newFiles} file${newFiles !== 1 ? "s" : ""}`);
1976
+ if (parsed.commits.length > 0) parts.push(`${parsed.commits.length} commit${parsed.commits.length !== 1 ? "s" : ""}`);
1977
+ if (newDecisions > 0) parts.push(`${newDecisions} decision${newDecisions !== 1 ? "s" : ""} captured`);
1978
+ const summary = parts.length > 0 ? parts.join(", ") : "no new data";
1979
+ console.log(chalk12.green(` \u2713 Session ingested \u2014 ${summary}`));
1980
+ await syncCommand({ silent: true });
1981
+ console.log(chalk12.gray(" \u21B3 PROJECT_STATE.md + AGENTS.md updated"));
1982
+ } catch (err) {
1983
+ console.log(chalk12.red(` \u2717 Ingest failed: ${err.message}`));
1984
+ }
1985
+ }
1986
+ function startWatcher(transcriptDir, projectPath) {
1987
+ const pending = /* @__PURE__ */ new Map();
1988
+ const ingested = /* @__PURE__ */ new Set();
1989
+ const fileWatchers = /* @__PURE__ */ new Map();
1990
+ function schedule(filePath) {
1991
+ if (ingested.has(filePath)) return;
1992
+ const existing = pending.get(filePath);
1993
+ if (existing) clearTimeout(existing);
1994
+ const timer = setTimeout(async () => {
1995
+ pending.delete(filePath);
1996
+ if (ingested.has(filePath)) return;
1997
+ if (fileSize(filePath) === 0) return;
1998
+ ingested.add(filePath);
1999
+ fileWatchers.get(filePath)?.close();
2000
+ fileWatchers.delete(filePath);
2001
+ await runIngest(filePath, projectPath);
2002
+ }, DEBOUNCE_MS);
2003
+ pending.set(filePath, timer);
2004
+ }
2005
+ function watchFile(filePath) {
2006
+ if (fileWatchers.has(filePath) || ingested.has(filePath)) return;
2007
+ try {
2008
+ const w = fsWatch(filePath, () => {
2009
+ schedule(filePath);
2010
+ });
2011
+ fileWatchers.set(filePath, w);
2012
+ } catch {
2013
+ }
2014
+ }
2015
+ if (existsSync6(transcriptDir)) {
2016
+ for (const f of readdirSync2(transcriptDir)) {
2017
+ if (f.endsWith(".jsonl")) {
2018
+ ingested.add(join8(transcriptDir, f));
2019
+ }
2020
+ }
2021
+ }
2022
+ fsWatch(transcriptDir, (_event, filename) => {
2023
+ if (!filename?.endsWith(".jsonl")) return;
2024
+ const fp = join8(transcriptDir, filename);
2025
+ if (!existsSync6(fp) || ingested.has(fp)) return;
2026
+ if (!fileWatchers.has(fp)) {
2027
+ watchFile(fp);
2028
+ schedule(fp);
2029
+ }
2030
+ });
2031
+ console.log(chalk12.bold("\n groundctl watch") + chalk12.gray(" \u2014 auto-ingest on session end\n"));
2032
+ console.log(
2033
+ chalk12.gray(" Watching: ") + chalk12.blue(transcriptDir.replace(homedir3(), "~"))
2034
+ );
2035
+ console.log(chalk12.gray(" Stability threshold: ") + chalk12.white(`${DEBOUNCE_MS / 1e3}s`));
2036
+ console.log(chalk12.gray(" Press Ctrl+C to stop.\n"));
2037
+ }
2038
+ async function watchCommand(options) {
2039
+ const projectPath = options.projectPath ? resolve2(options.projectPath) : process.cwd();
2040
+ if (options.daemon) {
2041
+ const args = [process.argv[1], "watch", "--project-path", projectPath];
2042
+ const child = spawn(process.execPath, args, {
2043
+ detached: true,
2044
+ stdio: "ignore"
2045
+ });
2046
+ child.unref();
2047
+ const groundctlDir2 = join8(projectPath, ".groundctl");
2048
+ writePidFile(groundctlDir2, child.pid);
2049
+ console.log(chalk12.green(`
2050
+ \u2713 groundctl watch running in background (PID ${child.pid})`));
2051
+ console.log(chalk12.gray(` PID saved to .groundctl/watch.pid`));
2052
+ console.log(chalk12.gray(` To stop: kill ${child.pid}
2053
+ `));
2054
+ process.exit(0);
2055
+ }
2056
+ const groundctlDir = join8(projectPath, ".groundctl");
2057
+ const existingPid = readPidFile(groundctlDir);
2058
+ if (existingPid && processAlive(existingPid)) {
2059
+ console.log(chalk12.yellow(`
2060
+ \u26A0 A watcher is already running (PID ${existingPid}).`));
2061
+ console.log(chalk12.gray(` To stop it: kill ${existingPid}
2062
+ `));
2063
+ process.exit(1);
2064
+ }
2065
+ let transcriptDir = findTranscriptDir(projectPath);
2066
+ if (!transcriptDir) {
2067
+ console.log(chalk12.bold("\n groundctl watch\n"));
2068
+ console.log(
2069
+ chalk12.yellow(" No Claude Code transcript directory found for this project yet.")
2070
+ );
2071
+ console.log(chalk12.gray(" Waiting for first session to start...\n"));
2072
+ await new Promise((resolve3) => {
2073
+ const interval = setInterval(() => {
2074
+ const dir = findTranscriptDir(projectPath);
2075
+ if (dir) {
2076
+ clearInterval(interval);
2077
+ transcriptDir = dir;
2078
+ resolve3();
2079
+ }
2080
+ }, DIR_POLL_MS);
2081
+ });
2082
+ }
2083
+ startWatcher(transcriptDir, projectPath);
2084
+ await new Promise(() => {
2085
+ process.on("SIGINT", () => {
2086
+ console.log(chalk12.gray("\n Watcher stopped.\n"));
2087
+ process.exit(0);
2088
+ });
2089
+ process.on("SIGTERM", () => {
2090
+ process.exit(0);
2091
+ });
2092
+ });
2093
+ }
2094
+
1848
2095
  // src/index.ts
1849
2096
  var require2 = createRequire(import.meta.url);
1850
2097
  var pkg = require2("../package.json");
@@ -1870,4 +2117,10 @@ program.command("ingest").description("Parse a transcript and write session data
1870
2117
  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);
1871
2118
  program.command("health").description("Show product health score").action(healthCommand);
1872
2119
  program.command("dashboard").description("Start web dashboard on port 4242").option("-p, --port <port>", "Port number", "4242").action(dashboardCommand);
2120
+ program.command("watch").description("Watch for session end and auto-ingest transcripts").option("--daemon", "Run in background (detached process)").option("--project-path <path>", "Project path (defaults to cwd)").action(
2121
+ (opts) => watchCommand({
2122
+ daemon: opts.daemon,
2123
+ projectPath: opts.projectPath
2124
+ })
2125
+ );
1873
2126
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundctl/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Product memory for AI agent builders",
5
5
  "license": "MIT",
6
6
  "bin": {