@groundctl/cli 0.4.0 → 0.5.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 +710 -260
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { createRequire } from "module";
5
+ import { createRequire as createRequire2 } from "module";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, chmodSync, readFileSync as readFileSync3, appendFileSync } from "fs";
9
9
  import { join as join3 } from "path";
10
+ import { homedir as homedir2 } from "os";
11
+ import { spawn, execSync as execSync3 } from "child_process";
12
+ import { createInterface as createInterface2 } from "readline";
10
13
  import chalk2 from "chalk";
11
14
 
12
15
  // src/storage/db.ts
@@ -98,6 +101,14 @@ function applySchema(db) {
98
101
  db.run("CREATE INDEX IF NOT EXISTS idx_claims_active ON claims(feature_id) WHERE released_at IS NULL");
99
102
  db.run("CREATE INDEX IF NOT EXISTS idx_files_session ON files_modified(session_id)");
100
103
  db.run("CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id)");
104
+ db.run(`
105
+ CREATE TABLE IF NOT EXISTS feature_groups (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ name TEXT NOT NULL UNIQUE,
108
+ label TEXT NOT NULL,
109
+ order_index INTEGER NOT NULL DEFAULT 0
110
+ )
111
+ `);
101
112
  const tryAlter = (sql) => {
102
113
  try {
103
114
  db.run(sql);
@@ -107,6 +118,7 @@ function applySchema(db) {
107
118
  tryAlter("ALTER TABLE features ADD COLUMN progress_done INTEGER");
108
119
  tryAlter("ALTER TABLE features ADD COLUMN progress_total INTEGER");
109
120
  tryAlter("ALTER TABLE features ADD COLUMN items TEXT");
121
+ tryAlter("ALTER TABLE features ADD COLUMN group_id INTEGER REFERENCES feature_groups(id)");
110
122
  db.run(
111
123
  "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
112
124
  [String(SCHEMA_VERSION)]
@@ -475,6 +487,10 @@ import { execSync as execSync2 } from "child_process";
475
487
  import { request as httpsRequest } from "https";
476
488
  import { createInterface } from "readline";
477
489
  import chalk from "chalk";
490
+ var PROXY_URL = "https://detect.groundctl.org/detect";
491
+ var USER_AGENT = "groundctl-cli/0.5.0 Node.js";
492
+ var MODEL = "claude-haiku-4-5-20251001";
493
+ var SYSTEM_PROMPT = "You are a product analyst. Analyze this project and identify the main product features.";
478
494
  function run2(cmd, cwd) {
479
495
  try {
480
496
  return execSync2(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
@@ -482,20 +498,9 @@ function run2(cmd, cwd) {
482
498
  return "";
483
499
  }
484
500
  }
485
- function collectContext(projectPath) {
486
- const parts = [];
501
+ function collectContextParts(projectPath) {
487
502
  const gitLog = run2("git log --oneline --no-merges", projectPath);
488
- if (gitLog.trim()) {
489
- const lines = gitLog.trim().split("\n").slice(0, 150).join("\n");
490
- parts.push(`## Git history (${lines.split("\n").length} commits)
491
- ${lines}`);
492
- }
493
- const diffStat = run2("git log --stat --no-merges --oneline -30", projectPath);
494
- if (diffStat.trim()) {
495
- parts.push(`## Recent commit file changes
496
- ${diffStat.trim().slice(0, 3e3)}`);
497
- }
498
- const find = run2(
503
+ const fileTree = run2(
499
504
  [
500
505
  "find . -type f",
501
506
  "-not -path '*/node_modules/*'",
@@ -516,211 +521,228 @@ ${diffStat.trim().slice(0, 3e3)}`);
516
521
  ].join(" "),
517
522
  projectPath
518
523
  );
519
- if (find.trim()) {
520
- parts.push(`## Project file structure
521
- ${find.trim()}`);
522
- }
523
524
  const readmePath = join2(projectPath, "README.md");
524
- if (existsSync2(readmePath)) {
525
- const readme = readFileSync2(readmePath, "utf-8").slice(0, 3e3);
526
- parts.push(`## README.md
527
- ${readme}`);
528
- }
525
+ const readme = existsSync2(readmePath) ? readFileSync2(readmePath, "utf-8").slice(0, 3e3) : void 0;
529
526
  const psPath = join2(projectPath, "PROJECT_STATE.md");
530
- if (existsSync2(psPath)) {
531
- const ps = readFileSync2(psPath, "utf-8").slice(0, 2e3);
532
- parts.push(`## Existing PROJECT_STATE.md
533
- ${ps}`);
527
+ const projectState = existsSync2(psPath) ? readFileSync2(psPath, "utf-8").slice(0, 2e3) : void 0;
528
+ return {
529
+ gitLog: gitLog.trim().split("\n").slice(0, 150).join("\n") || void 0,
530
+ fileTree: fileTree.trim() || void 0,
531
+ readme,
532
+ projectState
533
+ };
534
+ }
535
+ function buildContextString(parts) {
536
+ const sections = [];
537
+ if (parts.gitLog) sections.push(`## Git history
538
+ ${parts.gitLog}`);
539
+ if (parts.fileTree) sections.push(`## File structure
540
+ ${parts.fileTree}`);
541
+ if (parts.readme) sections.push(`## README
542
+ ${parts.readme}`);
543
+ if (parts.projectState) sections.push(`## Existing PROJECT_STATE.md
544
+ ${parts.projectState}`);
545
+ return sections.join("\n\n");
546
+ }
547
+ function normaliseFeatures(raw) {
548
+ return raw.map((f) => ({
549
+ name: String(f.name ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
550
+ status: f.status === "done" ? "done" : "open",
551
+ priority: ["critical", "high", "medium", "low"].includes(f.priority) ? f.priority : "medium",
552
+ description: String(f.description ?? "").slice(0, 120)
553
+ })).filter((f) => f.name.length >= 2);
554
+ }
555
+ function parseFeatureJson(text) {
556
+ const stripped = text.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "").trim();
557
+ let obj;
558
+ try {
559
+ obj = JSON.parse(stripped);
560
+ } catch {
561
+ const m = stripped.match(/\{[\s\S]*\}/);
562
+ if (!m) throw new Error("No JSON found in response");
563
+ obj = JSON.parse(m[0]);
534
564
  }
535
- return parts.join("\n\n");
565
+ if (!Array.isArray(obj.features)) throw new Error("Response missing 'features' array");
566
+ return normaliseFeatures(obj.features);
536
567
  }
537
- var SYSTEM_PROMPT = "You are a product analyst. Analyze this project and identify the main product features.";
538
- var USER_TEMPLATE = (context) => `Based on this git history and project structure, identify the product features with their status and priority.
539
-
540
- Rules:
541
- - Features are functional capabilities, not technical tasks
542
- - Maximum 12 features
543
- - status: "done" if all related commits are old and nothing is open, otherwise "open"
544
- - priority: critical/high/medium/low
545
- - name: short, kebab-case, human-readable (e.g. "user-auth", "data-pipeline")
546
- - description: one sentence, what the feature does for the user
547
-
548
- Respond ONLY with valid JSON, no markdown, no explanation:
549
- {"features":[{"name":"...","status":"done","priority":"high","description":"..."}]}
550
-
551
- Project context:
552
- ${context}`;
553
- function httpsPost(opts) {
568
+ function httpsPost(url, body, extraHeaders) {
554
569
  return new Promise((resolve3, reject) => {
555
- const body = JSON.stringify({
556
- model: opts.model,
557
- max_tokens: opts.maxTokens ?? 1024,
558
- system: opts.system,
559
- messages: [{ role: "user", content: opts.userMessage }]
560
- });
570
+ const bodyStr = JSON.stringify(body);
571
+ const parsed = new URL(url);
561
572
  const req = httpsRequest(
562
573
  {
563
- hostname: "api.anthropic.com",
564
- path: "/v1/messages",
574
+ hostname: parsed.hostname,
575
+ path: parsed.pathname + parsed.search,
565
576
  method: "POST",
566
577
  headers: {
567
- "x-api-key": opts.apiKey,
568
- "anthropic-version": "2023-06-01",
569
578
  "content-type": "application/json",
570
- "content-length": Buffer.byteLength(body)
579
+ "content-length": Buffer.byteLength(bodyStr),
580
+ "user-agent": USER_AGENT,
581
+ ...extraHeaders
571
582
  }
572
583
  },
573
584
  (res) => {
574
585
  let data = "";
575
- res.on("data", (chunk) => {
576
- data += chunk.toString();
586
+ res.on("data", (c) => {
587
+ data += c.toString();
577
588
  });
578
589
  res.on("end", () => {
579
- resolve3(data);
590
+ if ((res.statusCode ?? 200) >= 400) {
591
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
592
+ } else {
593
+ resolve3(data);
594
+ }
580
595
  });
581
596
  }
582
597
  );
598
+ req.setTimeout(15e3, () => {
599
+ req.destroy(new Error("Request timeout"));
600
+ });
583
601
  req.on("error", reject);
584
- req.write(body);
602
+ req.write(bodyStr);
585
603
  req.end();
586
604
  });
587
605
  }
588
- function extractText(raw) {
589
- const json = JSON.parse(raw);
590
- if (json.error) throw new Error(`API error: ${json.error.message}`);
591
- const block = (json.content ?? []).find((b) => b.type === "text");
592
- if (!block) throw new Error("No text block in API response");
593
- return block.text;
606
+ async function callProxy(parts) {
607
+ const raw = await httpsPost(PROXY_URL, parts);
608
+ const resp = JSON.parse(raw);
609
+ if (resp.error) throw new Error(resp.error);
610
+ if (!Array.isArray(resp.features)) throw new Error("No features in proxy response");
611
+ return normaliseFeatures(resp.features);
594
612
  }
595
- function parseFeatureJson(text) {
596
- const stripped = text.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "").trim();
597
- let obj;
598
- try {
599
- obj = JSON.parse(stripped);
600
- } catch {
601
- const match = stripped.match(/\{[\s\S]*\}/);
602
- if (!match) throw new Error("Could not parse JSON from model response");
603
- obj = JSON.parse(match[0]);
604
- }
605
- if (!Array.isArray(obj.features)) throw new Error("Response missing 'features' array");
606
- return obj.features.map((f) => ({
607
- name: String(f.name ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
608
- status: f.status === "done" ? "done" : "open",
609
- priority: ["critical", "high", "medium", "low"].includes(f.priority) ? f.priority : "medium",
610
- description: String(f.description ?? "").slice(0, 120)
611
- })).filter((f) => f.name.length >= 2);
612
- }
613
- async function callClaude(apiKey, context) {
614
- const raw = await httpsPost({
615
- apiKey,
616
- model: "claude-haiku-4-5-20251001",
617
- system: SYSTEM_PROMPT,
618
- userMessage: USER_TEMPLATE(context),
619
- maxTokens: 1024
620
- });
621
- const text = extractText(raw);
622
- return parseFeatureJson(text);
613
+ async function callDirectApi(apiKey, parts) {
614
+ const userMsg = `Based on this project context, identify the product features.
615
+
616
+ Rules:
617
+ - Features are functional capabilities, not technical tasks
618
+ - Maximum 12 features
619
+ - status: "done" if clearly shipped, otherwise "open"
620
+ - priority: critical/high/medium/low
621
+ - name: short, kebab-case
622
+
623
+ Respond ONLY with valid JSON, no markdown:
624
+ {"features":[{"name":"...","status":"done","priority":"high","description":"..."}]}
625
+
626
+ ${buildContextString(parts)}`;
627
+ const raw = await httpsPost(
628
+ "https://api.anthropic.com/v1/messages",
629
+ { model: MODEL, max_tokens: 1024, system: SYSTEM_PROMPT, messages: [{ role: "user", content: userMsg }] },
630
+ { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
631
+ );
632
+ const resp = JSON.parse(raw);
633
+ if (resp.error) throw new Error(resp.error.message);
634
+ const block = (resp.content ?? []).find((b) => b.type === "text");
635
+ if (!block) throw new Error("Empty response from API");
636
+ return parseFeatureJson(block.text);
637
+ }
638
+ function basicHeuristic(projectPath) {
639
+ const log = run2("git log --oneline --no-merges", projectPath);
640
+ if (!log.trim()) return [];
641
+ const seen = /* @__PURE__ */ new Set();
642
+ const features = [];
643
+ for (const line of log.trim().split("\n").slice(0, 60)) {
644
+ const msg = line.replace(/^[a-f0-9]+ /, "").toLowerCase();
645
+ const m = msg.match(/(?:feat(?:ure)?[:,\s]+|add\s+|implement\s+|build\s+|create\s+|setup\s+)([a-z][\w\s-]{2,40})/);
646
+ if (!m) continue;
647
+ const raw = m[1].trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
648
+ const name = raw.slice(0, 30);
649
+ if (!name || seen.has(name)) continue;
650
+ seen.add(name);
651
+ features.push({ name, status: "done", priority: "medium", description: `Detected from: ${line.slice(8, 80)}` });
652
+ if (features.length >= 10) break;
653
+ }
654
+ return features;
623
655
  }
624
656
  function renderFeatureList(features) {
625
657
  console.log(chalk.bold(`
626
658
  Detected ${features.length} features:
627
659
  `));
628
660
  for (const f of features) {
629
- const statusIcon = f.status === "done" ? chalk.green("\u2713") : chalk.gray("\u25CB");
630
- const prioColor = f.priority === "critical" || f.priority === "high" ? chalk.red : chalk.gray;
631
- console.log(
632
- ` ${statusIcon} ${chalk.white(f.name.padEnd(28))}` + prioColor(`(${f.priority}, ${f.status})`.padEnd(18)) + chalk.gray(f.description)
633
- );
661
+ const icon = f.status === "done" ? chalk.green("\u2713") : chalk.gray("\u25CB");
662
+ const prioCh = f.priority === "critical" || f.priority === "high" ? chalk.red : chalk.gray;
663
+ const meta = prioCh(`(${f.priority}, ${f.status})`.padEnd(16));
664
+ console.log(` ${icon} ${chalk.white(f.name.padEnd(28))}${meta} ${chalk.gray(f.description)}`);
634
665
  }
635
666
  console.log("");
636
667
  }
637
668
  function readLine(prompt) {
638
669
  const rl = createInterface({ input: process.stdin, output: process.stdout });
639
670
  return new Promise((resolve3) => {
640
- rl.question(prompt, (answer) => {
671
+ rl.question(prompt, (a) => {
641
672
  rl.close();
642
- resolve3(answer.trim().toLowerCase());
673
+ resolve3(a.trim().toLowerCase());
643
674
  });
644
675
  });
645
676
  }
646
677
  async function editInEditor(features) {
647
- const tmpPath = join2(tmpdir(), `groundctl-features-${Date.now()}.json`);
648
- writeFileSync2(tmpPath, JSON.stringify({ features }, null, 2), "utf-8");
678
+ const tmp = join2(tmpdir(), `groundctl-features-${Date.now()}.json`);
679
+ writeFileSync2(tmp, JSON.stringify({ features }, null, 2), "utf-8");
649
680
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
650
681
  try {
651
- execSync2(`${editor} "${tmpPath}"`, { stdio: "inherit" });
682
+ execSync2(`${editor} "${tmp}"`, { stdio: "inherit" });
652
683
  } catch {
653
- console.log(chalk.red(" Editor exited with error \u2014 using original features."));
654
684
  return features;
655
685
  }
656
686
  try {
657
- const edited = readFileSync2(tmpPath, "utf-8");
658
- return parseFeatureJson(edited);
659
- } catch (err) {
660
- console.log(chalk.red(` Could not parse edited JSON: ${err.message}`));
687
+ return parseFeatureJson(readFileSync2(tmp, "utf-8"));
688
+ } catch (e) {
689
+ console.log(chalk.red(` Parse error: ${e.message}`));
661
690
  return null;
662
691
  }
663
692
  }
664
693
  function importFeatures(db, features) {
665
- db.run(
666
- `DELETE FROM features
667
- WHERE id NOT IN (SELECT DISTINCT feature_id FROM claims)
668
- AND status = 'pending'`
669
- );
694
+ db.run(`DELETE FROM features WHERE id NOT IN (SELECT DISTINCT feature_id FROM claims) AND status = 'pending'`);
670
695
  for (const f of features) {
671
- const id = f.name;
672
696
  const status = f.status === "done" ? "done" : "pending";
673
- const exists = queryOne(db, "SELECT id FROM features WHERE id = ?", [id]);
674
- if (!exists) {
697
+ if (!queryOne(db, "SELECT id FROM features WHERE id = ?", [f.name])) {
675
698
  db.run(
676
699
  "INSERT INTO features (id, name, status, priority, description) VALUES (?, ?, ?, ?, ?)",
677
- [id, f.name, status, f.priority, f.description]
700
+ [f.name, f.name, status, f.priority, f.description]
678
701
  );
679
702
  } else {
680
703
  db.run(
681
- `UPDATE features
682
- SET description = ?, priority = ?, updated_at = datetime('now')
683
- WHERE id = ?`,
684
- [f.description, f.priority, id]
704
+ "UPDATE features SET description = ?, priority = ?, updated_at = datetime('now') WHERE id = ?",
705
+ [f.description, f.priority, f.name]
685
706
  );
686
707
  }
687
708
  }
688
709
  saveDb();
689
710
  }
690
711
  async function detectAndImportFeatures(db, projectPath) {
712
+ process.stdout.write(chalk.gray(" Detecting features..."));
713
+ const parts = collectContextParts(projectPath);
691
714
  const apiKey = process.env.ANTHROPIC_API_KEY;
692
- if (!apiKey) {
693
- console.log(chalk.yellow(
694
- "\n Smart feature detection disabled \u2014 ANTHROPIC_API_KEY not set."
695
- ));
696
- console.log(chalk.gray(" To enable:"));
697
- console.log(chalk.gray(" export ANTHROPIC_API_KEY=sk-ant-..."));
698
- console.log(chalk.gray(
699
- "\n Or add features manually:\n groundctl add feature -n 'my-feature'\n"
700
- ));
701
- return false;
702
- }
703
- console.log(chalk.gray(" Collecting project context..."));
704
- const context = collectContext(projectPath);
705
- console.log(chalk.gray(" Asking Claude to detect features..."));
706
- let features;
715
+ let features = [];
716
+ let source = "";
707
717
  try {
708
- features = await callClaude(apiKey, context);
709
- } catch (err) {
710
- console.log(chalk.red(` \u2717 Feature detection failed: ${err.message}`));
711
- console.log(chalk.gray(" Add features manually with: groundctl add feature -n 'my-feature'\n"));
712
- return false;
718
+ features = await callProxy(parts);
719
+ source = "proxy";
720
+ } catch {
721
+ if (apiKey) {
722
+ try {
723
+ features = await callDirectApi(apiKey, parts);
724
+ source = "api";
725
+ } catch {
726
+ features = basicHeuristic(projectPath);
727
+ source = "heuristic";
728
+ }
729
+ } else {
730
+ features = basicHeuristic(projectPath);
731
+ source = "heuristic";
732
+ }
713
733
  }
734
+ process.stdout.write("\r" + " ".repeat(30) + "\r");
714
735
  if (features.length === 0) {
715
- console.log(chalk.yellow(" No features detected \u2014 add them manually.\n"));
736
+ console.log(chalk.yellow(" No features detected \u2014 add them manually with groundctl add feature.\n"));
716
737
  return false;
717
738
  }
739
+ const sourceLabel = source === "proxy" ? chalk.green("(via detect.groundctl.org)") : source === "api" ? chalk.green("(via ANTHROPIC_API_KEY)") : chalk.yellow("(basic heuristic \u2014 set ANTHROPIC_API_KEY for better results)");
718
740
  renderFeatureList(features);
741
+ console.log(chalk.gray(` Source: ${sourceLabel}
742
+ `));
719
743
  let pending = features;
720
744
  while (true) {
721
- const answer = await readLine(
722
- chalk.bold(" Import these features? ") + chalk.gray("[y/n/edit] ") + ""
723
- );
745
+ const answer = await readLine(chalk.bold(" Import these features? ") + chalk.gray("[y/n/edit] "));
724
746
  if (answer === "y" || answer === "yes") {
725
747
  importFeatures(db, pending);
726
748
  console.log(chalk.green(`
@@ -729,7 +751,7 @@ async function detectAndImportFeatures(db, projectPath) {
729
751
  return true;
730
752
  }
731
753
  if (answer === "n" || answer === "no") {
732
- console.log(chalk.gray(" Skipped \u2014 no features imported.\n"));
754
+ console.log(chalk.gray(" Skipped.\n"));
733
755
  return false;
734
756
  }
735
757
  if (answer === "e" || answer === "edit") {
@@ -737,9 +759,7 @@ async function detectAndImportFeatures(db, projectPath) {
737
759
  if (edited && edited.length > 0) {
738
760
  pending = edited;
739
761
  renderFeatureList(pending);
740
- } else {
741
- console.log(chalk.yellow(" No valid features after edit \u2014 try again.\n"));
742
- }
762
+ } else console.log(chalk.yellow(" No valid features after edit.\n"));
743
763
  continue;
744
764
  }
745
765
  console.log(chalk.gray(" Please answer y, n, or edit."));
@@ -780,6 +800,90 @@ groundctl ingest \\
780
800
  groundctl sync 2>/dev/null || true
781
801
  echo "--- groundctl: Product state updated ---"
782
802
  `;
803
+ var LAUNCH_AGENT_ID = "org.groundctl.watch";
804
+ var LAUNCH_AGENT_PLIST_PATH = join3(homedir2(), "Library", "LaunchAgents", `${LAUNCH_AGENT_ID}.plist`);
805
+ function buildLaunchAgentPlist(projectPath) {
806
+ const binPath = process.argv[1];
807
+ return `<?xml version="1.0" encoding="UTF-8"?>
808
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
809
+ <plist version="1.0">
810
+ <dict>
811
+ <key>Label</key>
812
+ <string>${LAUNCH_AGENT_ID}</string>
813
+ <key>ProgramArguments</key>
814
+ <array>
815
+ <string>${process.execPath}</string>
816
+ <string>${binPath}</string>
817
+ <string>watch</string>
818
+ <string>--project-path</string>
819
+ <string>${projectPath}</string>
820
+ </array>
821
+ <key>RunAtLoad</key>
822
+ <true/>
823
+ <key>KeepAlive</key>
824
+ <false/>
825
+ <key>StandardOutPath</key>
826
+ <string>${join3(projectPath, ".groundctl", "watch.log")}</string>
827
+ <key>StandardErrorPath</key>
828
+ <string>${join3(projectPath, ".groundctl", "watch.log")}</string>
829
+ </dict>
830
+ </plist>
831
+ `;
832
+ }
833
+ function readLine2(prompt) {
834
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
835
+ return new Promise((resolve3) => {
836
+ rl.question(prompt, (a) => {
837
+ rl.close();
838
+ resolve3(a.trim().toLowerCase());
839
+ });
840
+ });
841
+ }
842
+ function startWatchDaemon(projectPath) {
843
+ try {
844
+ const args = [process.argv[1], "watch", "--project-path", projectPath];
845
+ const child = spawn(process.execPath, args, {
846
+ detached: true,
847
+ stdio: "ignore"
848
+ });
849
+ child.unref();
850
+ const pid = child.pid ?? null;
851
+ if (pid) {
852
+ const groundctlDir = join3(projectPath, ".groundctl");
853
+ mkdirSync2(groundctlDir, { recursive: true });
854
+ writeFileSync3(join3(groundctlDir, "watch.pid"), String(pid), "utf8");
855
+ }
856
+ return pid;
857
+ } catch {
858
+ return null;
859
+ }
860
+ }
861
+ function watchDaemonRunning(projectPath) {
862
+ try {
863
+ const pidPath = join3(projectPath, ".groundctl", "watch.pid");
864
+ if (!existsSync3(pidPath)) return false;
865
+ const pid = parseInt(readFileSync3(pidPath, "utf8").trim());
866
+ if (!pid) return false;
867
+ process.kill(pid, 0);
868
+ return true;
869
+ } catch {
870
+ return false;
871
+ }
872
+ }
873
+ function installLaunchAgent(projectPath) {
874
+ try {
875
+ const laDir = join3(homedir2(), "Library", "LaunchAgents");
876
+ mkdirSync2(laDir, { recursive: true });
877
+ writeFileSync3(LAUNCH_AGENT_PLIST_PATH, buildLaunchAgentPlist(projectPath), "utf8");
878
+ try {
879
+ execSync3(`launchctl load "${LAUNCH_AGENT_PLIST_PATH}"`, { stdio: "ignore" });
880
+ } catch {
881
+ }
882
+ return true;
883
+ } catch {
884
+ return false;
885
+ }
886
+ }
783
887
  async function initCommand(options) {
784
888
  const cwd = process.cwd();
785
889
  const projectName = cwd.split("/").pop() ?? "unknown";
@@ -837,6 +941,37 @@ groundctl init \u2014 ${projectName}
837
941
  appendFileSync(gitignorePath, gitignoreEntry);
838
942
  }
839
943
  }
944
+ console.log("");
945
+ if (watchDaemonRunning(cwd)) {
946
+ console.log(chalk2.green(" \u2713 Watch daemon already running"));
947
+ } else {
948
+ const pid = startWatchDaemon(cwd);
949
+ if (pid) {
950
+ console.log(chalk2.green(` \u2713 Watch daemon started`) + chalk2.gray(` (PID ${pid})`));
951
+ } else {
952
+ console.log(chalk2.yellow(" \u26A0 Could not start watch daemon \u2014 run: groundctl watch --daemon"));
953
+ }
954
+ }
955
+ if (process.platform === "darwin") {
956
+ const laInstalled = existsSync3(LAUNCH_AGENT_PLIST_PATH);
957
+ if (!laInstalled) {
958
+ const answer = await readLine2(
959
+ chalk2.bold(" Start groundctl watch on login? (recommended) ") + chalk2.gray("[y/n] ")
960
+ );
961
+ if (answer === "y" || answer === "yes") {
962
+ const ok2 = installLaunchAgent(cwd);
963
+ if (ok2) {
964
+ console.log(chalk2.green(" \u2713 LaunchAgent installed") + chalk2.gray(` (${LAUNCH_AGENT_PLIST_PATH.replace(homedir2(), "~")})`));
965
+ } else {
966
+ console.log(chalk2.yellow(" \u26A0 LaunchAgent install failed \u2014 run: groundctl doctor"));
967
+ }
968
+ } else {
969
+ console.log(chalk2.gray(" Skipped. You can install later: groundctl doctor"));
970
+ }
971
+ } else {
972
+ console.log(chalk2.green(" \u2713 LaunchAgent already installed"));
973
+ }
974
+ }
840
975
  console.log(chalk2.bold.green(`
841
976
  \u2713 groundctl initialized for ${projectName}
842
977
  `));
@@ -856,7 +991,9 @@ groundctl init \u2014 ${projectName}
856
991
 
857
992
  // src/commands/status.ts
858
993
  import chalk3 from "chalk";
859
- var BAR_W = 14;
994
+ var AGG_BAR_W = 20;
995
+ var GRP_BAR_W = 20;
996
+ var FEAT_BAR_W = 14;
860
997
  var NAME_W = 22;
861
998
  var PROG_W = 6;
862
999
  function progressBar(done, total, width) {
@@ -864,110 +1001,162 @@ function progressBar(done, total, width) {
864
1001
  const filled = Math.min(width, Math.round(done / total * width));
865
1002
  return chalk3.green("\u2588".repeat(filled)) + chalk3.gray("\u2591".repeat(width - filled));
866
1003
  }
867
- function featureBar(status, progressDone, progressTotal) {
868
- if (progressTotal != null && progressTotal > 0) {
869
- return progressBar(progressDone ?? 0, progressTotal, BAR_W);
870
- }
1004
+ function featureBar(status, pd, pt, width = FEAT_BAR_W) {
1005
+ if (pt != null && pt > 0) return progressBar(pd ?? 0, pt, width);
871
1006
  switch (status) {
872
1007
  case "done":
873
- return progressBar(1, 1, BAR_W);
1008
+ return progressBar(1, 1, width);
874
1009
  case "in_progress":
875
- return progressBar(1, 2, BAR_W);
1010
+ return progressBar(1, 2, width);
876
1011
  case "blocked":
877
- return chalk3.red("\u2591".repeat(BAR_W));
1012
+ return chalk3.red("\u2591".repeat(width));
878
1013
  default:
879
- return chalk3.gray("\u2591".repeat(BAR_W));
880
- }
881
- }
882
- function featureProgress(progressDone, progressTotal) {
883
- if (progressDone != null && progressTotal != null) {
884
- return `${progressDone}/${progressTotal}`;
1014
+ return chalk3.gray("\u2591".repeat(width));
885
1015
  }
886
- return "";
887
1016
  }
888
- function wrapItems(itemsCsv, maxWidth) {
889
- const items = itemsCsv.split(",").map((s) => s.trim()).filter(Boolean);
1017
+ function wrapItems(csv, maxWidth) {
1018
+ const items = csv.split(",").map((s) => s.trim()).filter(Boolean);
890
1019
  const lines = [];
891
- let current = "";
1020
+ let cur = "";
892
1021
  for (const item of items) {
893
- const next = current ? `${current} \xB7 ${item}` : item;
894
- if (next.length > maxWidth && current.length > 0) {
895
- lines.push(current);
896
- current = item;
897
- } else {
898
- current = next;
899
- }
1022
+ const next = cur ? `${cur} \xB7 ${item}` : item;
1023
+ if (next.length > maxWidth && cur.length > 0) {
1024
+ lines.push(cur);
1025
+ cur = item;
1026
+ } else cur = next;
900
1027
  }
901
- if (current) lines.push(current);
1028
+ if (cur) lines.push(cur);
902
1029
  return lines;
903
1030
  }
904
- function timeSince(isoDate) {
905
- const then = (/* @__PURE__ */ new Date(isoDate + "Z")).getTime();
906
- const ms = Date.now() - then;
907
- const mins = Math.floor(ms / 6e4);
908
- if (mins < 60) return `${mins}m`;
909
- const h = Math.floor(mins / 60);
910
- const m = mins % 60;
911
- return `${h}h${m > 0 ? String(m).padStart(2, "0") : ""}`;
1031
+ function timeSince(iso) {
1032
+ const ms = Date.now() - (/* @__PURE__ */ new Date(iso + "Z")).getTime();
1033
+ const m = Math.floor(ms / 6e4);
1034
+ if (m < 60) return `${m}m`;
1035
+ const h = Math.floor(m / 60);
1036
+ return `${h}h${m % 60 > 0 ? String(m % 60).padStart(2, "0") : ""}`;
912
1037
  }
913
- async function statusCommand() {
914
- const db = await openDb();
915
- const projectName = process.cwd().split("/").pop() ?? "unknown";
916
- const features = query(
917
- db,
918
- `SELECT
919
- f.id, f.name, f.status, f.priority,
920
- f.description, f.progress_done, f.progress_total, f.items,
921
- c.session_id AS claimed_session,
922
- c.claimed_at AS claimed_at
923
- FROM features f
924
- LEFT JOIN claims c
925
- ON c.feature_id = f.id AND c.released_at IS NULL
926
- ORDER BY
927
- CASE f.status
928
- WHEN 'in_progress' THEN 0
929
- WHEN 'blocked' THEN 1
930
- WHEN 'pending' THEN 2
931
- WHEN 'done' THEN 3
932
- END,
933
- CASE f.priority
934
- WHEN 'critical' THEN 0
935
- WHEN 'high' THEN 1
936
- WHEN 'medium' THEN 2
937
- WHEN 'low' THEN 3
938
- END,
939
- f.created_at`
1038
+ function renderGroupSummary(features, groups, sessionCount, projectName) {
1039
+ const total = features.length;
1040
+ const done = features.filter((f) => f.status === "done").length;
1041
+ const pct = total > 0 ? Math.round(done / total * 100) : 0;
1042
+ console.log("");
1043
+ console.log(
1044
+ chalk3.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk3.gray(` (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`)
940
1045
  );
941
- const sessionCount = queryOne(
942
- db,
943
- "SELECT COUNT(*) as count FROM sessions"
944
- )?.count ?? 0;
945
- closeDb();
946
1046
  console.log("");
947
- if (features.length === 0) {
948
- console.log(chalk3.bold(` ${projectName} \u2014 no features tracked yet
949
- `));
950
- console.log(chalk3.gray(" Add features with: groundctl add feature -n 'my-feature'"));
951
- console.log(chalk3.gray(" Then run: groundctl status\n"));
952
- return;
1047
+ const maxLabelW = Math.max(...groups.map((g) => g.label.length), 14);
1048
+ for (const grp of groups) {
1049
+ const gFeatures = features.filter((f) => f.group_id === grp.id);
1050
+ if (gFeatures.length === 0) continue;
1051
+ const gDone = gFeatures.filter((f) => f.status === "done").length;
1052
+ const gActive = gFeatures.filter((f) => f.status === "in_progress").length;
1053
+ const bar2 = progressBar(gDone, gFeatures.length, GRP_BAR_W);
1054
+ const frac = chalk3.white(` ${gDone}/${gFeatures.length} done`);
1055
+ const inProg = gActive > 0 ? chalk3.yellow(` ${gActive} active`) : "";
1056
+ const label = grp.label.padEnd(maxLabelW);
1057
+ console.log(` ${chalk3.bold(label)} ${bar2}${frac}${inProg}`);
1058
+ }
1059
+ const ungrouped = features.filter((f) => f.group_id == null);
1060
+ if (ungrouped.length > 0) {
1061
+ const uDone = ungrouped.filter((f) => f.status === "done").length;
1062
+ const bar2 = progressBar(uDone, ungrouped.length, GRP_BAR_W);
1063
+ const label = "Other".padEnd(maxLabelW);
1064
+ console.log(` ${chalk3.gray(label)} ${bar2} ${chalk3.gray(`${uDone}/${ungrouped.length} done`)}`);
953
1065
  }
1066
+ console.log("");
1067
+ const claimed = features.filter((f) => f.status === "in_progress" && f.claimed_session);
1068
+ if (claimed.length > 0) {
1069
+ console.log(chalk3.bold(" Claimed:"));
1070
+ for (const f of claimed) {
1071
+ const grpLabel = f.group_label ? chalk3.gray(` (${f.group_label})`) : "";
1072
+ const elapsed = f.claimed_at ? timeSince(f.claimed_at) : "";
1073
+ console.log(chalk3.yellow(` \u25CF ${f.name}${grpLabel} \u2192 ${f.claimed_session}${elapsed ? ` (${elapsed})` : ""}`));
1074
+ }
1075
+ console.log("");
1076
+ }
1077
+ const next = features.find((f) => f.status === "pending" && !f.claimed_session);
1078
+ if (next) {
1079
+ const grpLabel = next.group_label ? chalk3.gray(` (${next.group_label})`) : "";
1080
+ console.log(chalk3.bold(" Next: ") + chalk3.white(`${next.name}${grpLabel}`));
1081
+ console.log("");
1082
+ }
1083
+ }
1084
+ function renderDetail(features, groups, sessionCount, projectName) {
1085
+ const total = features.length;
1086
+ const done = features.filter((f) => f.status === "done").length;
1087
+ const pct = Math.round(done / total * 100);
1088
+ console.log("");
1089
+ console.log(
1090
+ chalk3.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk3.gray(` (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`)
1091
+ );
1092
+ console.log("");
1093
+ console.log(` Features ${progressBar(done, total, AGG_BAR_W)} ${done}/${total} done`);
1094
+ console.log("");
1095
+ const nameW = Math.min(NAME_W, Math.max(12, ...features.map((f) => f.name.length)));
1096
+ const contIndent = " ".repeat(4 + nameW + 1);
1097
+ const itemsMaxW = Math.max(40, 76 - contIndent.length);
1098
+ const renderFeature = (f, indent = " ") => {
1099
+ const isDone = f.status === "done";
1100
+ const isActive = f.status === "in_progress";
1101
+ const isBlocked = f.status === "blocked";
1102
+ const icon = isDone ? "\u2713" : isActive ? "\u25CF" : isBlocked ? "\u2717" : "\u25CB";
1103
+ const iconCh = isDone ? chalk3.green : isActive ? chalk3.yellow : isBlocked ? chalk3.red : chalk3.gray;
1104
+ const nameCh = isDone ? chalk3.dim : isActive ? chalk3.white : isBlocked ? chalk3.red : chalk3.gray;
1105
+ const nameRaw = f.name.slice(0, nameW).padEnd(nameW);
1106
+ const bar2 = featureBar(f.status, f.progress_done ?? null, f.progress_total ?? null);
1107
+ const prog = (f.progress_done != null ? `${f.progress_done}/${f.progress_total}` : "").padEnd(PROG_W);
1108
+ const descRaw = f.description ?? "";
1109
+ const descStr = descRaw ? chalk3.gray(` ${descRaw.length > 38 ? descRaw.slice(0, 36) + "\u2026" : descRaw}`) : "";
1110
+ let claimed = "";
1111
+ if (isActive && f.claimed_session) {
1112
+ const el = f.claimed_at ? timeSince(f.claimed_at) : "";
1113
+ claimed = chalk3.yellow(` \u2192 ${f.claimed_session}${el ? ` (${el})` : ""}`);
1114
+ }
1115
+ process.stdout.write(`${indent}${iconCh(icon)} ${nameCh(nameRaw)} ${bar2} ${prog}${descStr}${claimed}
1116
+ `);
1117
+ if (f.items) {
1118
+ for (const line of wrapItems(f.items, itemsMaxW)) {
1119
+ console.log(chalk3.dim(`${indent} ${" ".repeat(nameW + 2)}${line}`));
1120
+ }
1121
+ }
1122
+ };
1123
+ for (const grp of groups) {
1124
+ const gFeatures = features.filter((f) => f.group_id === grp.id);
1125
+ if (gFeatures.length === 0) continue;
1126
+ const gDone = gFeatures.filter((f) => f.status === "done").length;
1127
+ const gActive = gFeatures.filter((f) => f.status === "in_progress").length;
1128
+ const bar2 = progressBar(gDone, gFeatures.length, GRP_BAR_W);
1129
+ const inProg = gActive > 0 ? chalk3.yellow(` ${gActive} active`) : "";
1130
+ console.log(
1131
+ chalk3.bold.white(` ${grp.label.toUpperCase().padEnd(NAME_W + 1)} `) + `${bar2} ${gDone}/${gFeatures.length} done${inProg}`
1132
+ );
1133
+ for (const f of gFeatures) renderFeature(f, " ");
1134
+ console.log("");
1135
+ }
1136
+ const ungrouped = features.filter((f) => f.group_id == null);
1137
+ if (ungrouped.length > 0) {
1138
+ console.log(chalk3.bold.gray(" OTHER"));
1139
+ for (const f of ungrouped) renderFeature(f, " ");
1140
+ console.log("");
1141
+ }
1142
+ }
1143
+ function renderFlat(features, sessionCount, projectName) {
954
1144
  const total = features.length;
955
1145
  const done = features.filter((f) => f.status === "done").length;
956
1146
  const inProg = features.filter((f) => f.status === "in_progress").length;
957
1147
  const blocked = features.filter((f) => f.status === "blocked").length;
958
1148
  const pct = Math.round(done / total * 100);
1149
+ console.log("");
959
1150
  console.log(
960
1151
  chalk3.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk3.gray(` (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`)
961
1152
  );
962
1153
  console.log("");
963
- const aggBar = progressBar(done, total, 20);
964
1154
  let aggSuffix = chalk3.white(` ${done}/${total} done`);
965
1155
  if (inProg > 0) aggSuffix += chalk3.yellow(` ${inProg} in progress`);
966
1156
  if (blocked > 0) aggSuffix += chalk3.red(` ${blocked} blocked`);
967
- console.log(` Features ${aggBar}${aggSuffix}`);
1157
+ console.log(` Features ${progressBar(done, total, AGG_BAR_W)}${aggSuffix}`);
968
1158
  console.log("");
969
- const maxNameLen = Math.min(NAME_W, Math.max(...features.map((f) => f.name.length)));
970
- const nameW = Math.max(maxNameLen, 12);
1159
+ const nameW = Math.min(NAME_W, Math.max(12, ...features.map((f) => f.name.length)));
971
1160
  const contIndent = " ".repeat(4 + nameW + 1);
972
1161
  const itemsMaxW = Math.max(40, 76 - contIndent.length);
973
1162
  for (const f of features) {
@@ -975,33 +1164,84 @@ async function statusCommand() {
975
1164
  const isActive = f.status === "in_progress";
976
1165
  const isBlocked = f.status === "blocked";
977
1166
  const icon = isDone ? "\u2713" : isActive ? "\u25CF" : isBlocked ? "\u2717" : "\u25CB";
978
- const iconChalk = isDone ? chalk3.green : isActive ? chalk3.yellow : isBlocked ? chalk3.red : chalk3.gray;
1167
+ const iconCh = isDone ? chalk3.green : isActive ? chalk3.yellow : isBlocked ? chalk3.red : chalk3.gray;
1168
+ const nameCh = isDone ? chalk3.dim : isActive ? chalk3.white : isBlocked ? chalk3.red : chalk3.gray;
979
1169
  const nameRaw = f.name.slice(0, nameW).padEnd(nameW);
980
- const nameChalk = isDone ? chalk3.dim : isActive ? chalk3.white : isBlocked ? chalk3.red : chalk3.gray;
981
- const pd = f.progress_done ?? null;
982
- const pt = f.progress_total ?? null;
983
- const bar2 = featureBar(f.status, pd, pt);
984
- const prog = featureProgress(pd, pt).padEnd(PROG_W);
985
- const descRaw = f.description ?? "";
986
- const descTrunc = descRaw.length > 38 ? descRaw.slice(0, 36) + "\u2026" : descRaw;
987
- const descStr = descTrunc ? chalk3.gray(` ${descTrunc}`) : "";
988
- let claimedStr = "";
1170
+ const bar2 = featureBar(f.status, f.progress_done ?? null, f.progress_total ?? null);
1171
+ const prog = (f.progress_done != null ? `${f.progress_done}/${f.progress_total}` : "").padEnd(PROG_W);
1172
+ const desc = (f.description ?? "").slice(0, 38);
1173
+ const descStr = desc ? chalk3.gray(` ${desc.length < (f.description?.length ?? 0) ? desc + "\u2026" : desc}`) : "";
1174
+ let claimed = "";
989
1175
  if (isActive && f.claimed_session) {
990
- const elapsed = f.claimed_at ? timeSince(f.claimed_at) : "";
991
- claimedStr = chalk3.yellow(` \u2192 ${f.claimed_session}${elapsed ? ` (${elapsed})` : ""}`);
1176
+ const el = f.claimed_at ? timeSince(f.claimed_at) : "";
1177
+ claimed = chalk3.yellow(` \u2192 ${f.claimed_session}${el ? ` (${el})` : ""}`);
992
1178
  }
993
- console.log(
994
- ` ${iconChalk(icon)} ${nameChalk(nameRaw)} ${bar2} ${prog}${descStr}${claimedStr}`
995
- );
1179
+ console.log(` ${iconCh(icon)} ${nameCh(nameRaw)} ${bar2} ${prog}${descStr}${claimed}`);
996
1180
  if (f.items) {
997
- const lines = wrapItems(f.items, itemsMaxW);
998
- for (const line of lines) {
1181
+ for (const line of wrapItems(f.items, itemsMaxW)) {
999
1182
  console.log(chalk3.dim(`${contIndent}${line}`));
1000
1183
  }
1001
1184
  }
1002
1185
  }
1003
1186
  console.log("");
1004
1187
  }
1188
+ async function statusCommand(opts) {
1189
+ const db = await openDb();
1190
+ const projectName = process.cwd().split("/").pop() ?? "unknown";
1191
+ const features = query(
1192
+ db,
1193
+ `SELECT
1194
+ f.id, f.name, f.status, f.priority,
1195
+ f.description, f.progress_done, f.progress_total, f.items,
1196
+ f.group_id,
1197
+ g.name AS group_name,
1198
+ g.label AS group_label,
1199
+ g.order_index AS group_order,
1200
+ c.session_id AS claimed_session,
1201
+ c.claimed_at AS claimed_at
1202
+ FROM features f
1203
+ LEFT JOIN feature_groups g ON f.group_id = g.id
1204
+ LEFT JOIN claims c ON c.feature_id = f.id AND c.released_at IS NULL
1205
+ ORDER BY
1206
+ COALESCE(g.order_index, 9999),
1207
+ CASE f.status
1208
+ WHEN 'in_progress' THEN 0
1209
+ WHEN 'blocked' THEN 1
1210
+ WHEN 'pending' THEN 2
1211
+ WHEN 'done' THEN 3
1212
+ END,
1213
+ CASE f.priority
1214
+ WHEN 'critical' THEN 0 WHEN 'high' THEN 1
1215
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3
1216
+ END,
1217
+ f.created_at`
1218
+ );
1219
+ const groups = query(
1220
+ db,
1221
+ "SELECT id, name, label, order_index FROM feature_groups ORDER BY order_index"
1222
+ );
1223
+ const sessionCount = queryOne(
1224
+ db,
1225
+ "SELECT COUNT(*) as count FROM sessions"
1226
+ )?.count ?? 0;
1227
+ closeDb();
1228
+ if (features.length === 0) {
1229
+ console.log("");
1230
+ console.log(chalk3.bold(` ${projectName} \u2014 no features tracked yet
1231
+ `));
1232
+ console.log(chalk3.gray(" Add features: groundctl add feature -n 'my-feature'"));
1233
+ console.log(chalk3.gray(" Add groups: groundctl add group -n 'core' --label 'Core'\n"));
1234
+ return;
1235
+ }
1236
+ const hasGroups = groups.length > 0 && features.some((f) => f.group_id != null);
1237
+ if (opts?.detail || opts?.all) {
1238
+ renderDetail(features, groups, sessionCount, projectName);
1239
+ } else if (hasGroups) {
1240
+ renderGroupSummary(features, groups, sessionCount, projectName);
1241
+ } else {
1242
+ renderFlat(features, sessionCount, projectName);
1243
+ }
1244
+ }
1005
1245
 
1006
1246
  // src/commands/claim.ts
1007
1247
  import chalk4 from "chalk";
@@ -1300,9 +1540,7 @@ async function addCommand(type, options) {
1300
1540
  if (p) {
1301
1541
  progressDone = p.done;
1302
1542
  progressTotal = p.total;
1303
- } else {
1304
- console.log(chalk8.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)`));
1305
- }
1543
+ } else console.log(chalk8.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)`));
1306
1544
  }
1307
1545
  const items = options.items ? options.items.split(",").map((s) => s.trim()).filter(Boolean).join(",") : null;
1308
1546
  db.run(
@@ -1327,6 +1565,36 @@ async function addCommand(type, options) {
1327
1565
  const suffix = extras.length ? chalk8.gray(` \u2014 ${extras.join(", ")}`) : "";
1328
1566
  console.log(chalk8.green(`
1329
1567
  \u2713 Feature added: ${options.name} (${priority})${suffix}
1568
+ `));
1569
+ } else if (type === "group") {
1570
+ if (!options.name) {
1571
+ console.log(chalk8.red("\n --name is required for groups.\n"));
1572
+ closeDb();
1573
+ process.exit(1);
1574
+ }
1575
+ if (!options.label) {
1576
+ console.log(chalk8.red("\n --label is required for groups (display name).\n"));
1577
+ closeDb();
1578
+ process.exit(1);
1579
+ }
1580
+ const name = options.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
1581
+ const maxOrder = queryOne(db, "SELECT COALESCE(MAX(order_index),0) as m FROM feature_groups")?.m ?? 0;
1582
+ const exists = queryOne(db, "SELECT id FROM feature_groups WHERE name = ?", [name]);
1583
+ if (exists) {
1584
+ console.log(chalk8.yellow(`
1585
+ Group "${name}" already exists.
1586
+ `));
1587
+ closeDb();
1588
+ return;
1589
+ }
1590
+ db.run(
1591
+ "INSERT INTO feature_groups (name, label, order_index) VALUES (?, ?, ?)",
1592
+ [name, options.label, maxOrder + 1]
1593
+ );
1594
+ saveDb();
1595
+ closeDb();
1596
+ console.log(chalk8.green(`
1597
+ \u2713 Group added: ${options.label} (${name})
1330
1598
  `));
1331
1599
  } else if (type === "session") {
1332
1600
  const id = options.name ?? randomUUID2().slice(0, 8);
@@ -1339,7 +1607,7 @@ async function addCommand(type, options) {
1339
1607
  `));
1340
1608
  } else {
1341
1609
  console.log(chalk8.red(`
1342
- Unknown type "${type}". Use "feature" or "session".
1610
+ Unknown type "${type}". Use "feature", "group", or "session".
1343
1611
  `));
1344
1612
  closeDb();
1345
1613
  process.exit(1);
@@ -1349,7 +1617,7 @@ async function addCommand(type, options) {
1349
1617
  // src/commands/ingest.ts
1350
1618
  import { existsSync as existsSync4, readdirSync } from "fs";
1351
1619
  import { join as join5, resolve } from "path";
1352
- import { homedir as homedir2 } from "os";
1620
+ import { homedir as homedir3 } from "os";
1353
1621
  import chalk9 from "chalk";
1354
1622
 
1355
1623
  // src/ingest/claude-parser.ts
@@ -1585,7 +1853,7 @@ function claudeEncode(p) {
1585
1853
  return p.replace(/[^a-zA-Z0-9]/g, "-");
1586
1854
  }
1587
1855
  function findLatestTranscript(projectPath) {
1588
- const projectsDir = join5(homedir2(), ".claude", "projects");
1856
+ const projectsDir = join5(homedir3(), ".claude", "projects");
1589
1857
  if (!existsSync4(projectsDir)) return null;
1590
1858
  let transcriptDir = null;
1591
1859
  const projectKey = claudeEncode(projectPath);
@@ -2163,8 +2431,8 @@ import {
2163
2431
  watch as fsWatch
2164
2432
  } from "fs";
2165
2433
  import { join as join8, resolve as resolve2 } from "path";
2166
- import { homedir as homedir3 } from "os";
2167
- import { spawn } from "child_process";
2434
+ import { homedir as homedir4 } from "os";
2435
+ import { spawn as spawn2 } from "child_process";
2168
2436
  import chalk13 from "chalk";
2169
2437
  var DEBOUNCE_MS = 8e3;
2170
2438
  var DIR_POLL_MS = 5e3;
@@ -2172,7 +2440,7 @@ function claudeEncode2(p) {
2172
2440
  return p.replace(/[^a-zA-Z0-9]/g, "-");
2173
2441
  }
2174
2442
  function findTranscriptDir(projectPath) {
2175
- const projectsDir = join8(homedir3(), ".claude", "projects");
2443
+ const projectsDir = join8(homedir4(), ".claude", "projects");
2176
2444
  if (!existsSync6(projectsDir)) return null;
2177
2445
  const projectKey = claudeEncode2(projectPath);
2178
2446
  const direct = join8(projectsDir, projectKey);
@@ -2331,7 +2599,7 @@ function startWatcher(transcriptDir, projectPath) {
2331
2599
  });
2332
2600
  console.log(chalk13.bold("\n groundctl watch") + chalk13.gray(" \u2014 auto-ingest on session end\n"));
2333
2601
  console.log(
2334
- chalk13.gray(" Watching: ") + chalk13.blue(transcriptDir.replace(homedir3(), "~"))
2602
+ chalk13.gray(" Watching: ") + chalk13.blue(transcriptDir.replace(homedir4(), "~"))
2335
2603
  );
2336
2604
  console.log(chalk13.gray(" Stability threshold: ") + chalk13.white(`${DEBOUNCE_MS / 1e3}s`));
2337
2605
  console.log(chalk13.gray(" Press Ctrl+C to stop.\n"));
@@ -2340,7 +2608,7 @@ async function watchCommand(options) {
2340
2608
  const projectPath = options.projectPath ? resolve2(options.projectPath) : process.cwd();
2341
2609
  if (options.daemon) {
2342
2610
  const args = [process.argv[1], "watch", "--project-path", projectPath];
2343
- const child = spawn(process.execPath, args, {
2611
+ const child = spawn2(process.execPath, args, {
2344
2612
  detached: true,
2345
2613
  stdio: "ignore"
2346
2614
  });
@@ -2453,6 +2721,29 @@ async function updateCommand(type, nameOrId, options) {
2453
2721
  sets.push("status = ?");
2454
2722
  params.push(options.status);
2455
2723
  }
2724
+ if (options.group !== void 0) {
2725
+ if (options.group === "" || options.group === "none") {
2726
+ sets.push("group_id = ?");
2727
+ params.push(null);
2728
+ } else {
2729
+ const slug = options.group.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
2730
+ const grp = queryOne(
2731
+ db,
2732
+ "SELECT id, label FROM feature_groups WHERE name = ? OR label = ? LIMIT 1",
2733
+ [slug, options.group]
2734
+ );
2735
+ if (!grp) {
2736
+ console.log(chalk14.red(`
2737
+ Group "${options.group}" not found. Create it first:
2738
+ groundctl add group -n "${slug}" --label "${options.group}"
2739
+ `));
2740
+ closeDb();
2741
+ process.exit(1);
2742
+ }
2743
+ sets.push("group_id = ?");
2744
+ params.push(grp.id);
2745
+ }
2746
+ }
2456
2747
  if (sets.length === 0) {
2457
2748
  console.log(chalk14.yellow("\n Nothing to update \u2014 pass at least one option.\n"));
2458
2749
  closeDb();
@@ -2469,19 +2760,167 @@ async function updateCommand(type, nameOrId, options) {
2469
2760
  console.log(chalk14.green(` \u2713 Updated: ${feature.name}`));
2470
2761
  }
2471
2762
 
2472
- // src/index.ts
2763
+ // src/commands/doctor.ts
2764
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
2765
+ import { join as join9 } from "path";
2766
+ import { homedir as homedir5 } from "os";
2767
+ import { createRequire } from "module";
2768
+ import { request as httpsRequest2 } from "https";
2769
+ import chalk15 from "chalk";
2473
2770
  var require2 = createRequire(import.meta.url);
2474
2771
  var pkg = require2("../package.json");
2772
+ var LAUNCH_AGENT_PLIST = join9(homedir5(), "Library", "LaunchAgents", "org.groundctl.watch.plist");
2773
+ function ok(msg) {
2774
+ console.log(chalk15.green(" \u2713 ") + msg);
2775
+ }
2776
+ function warn(msg) {
2777
+ console.log(chalk15.yellow(" \u26A0 ") + msg);
2778
+ }
2779
+ function info(msg) {
2780
+ console.log(chalk15.gray(" " + msg));
2781
+ }
2782
+ function processAlive2(pid) {
2783
+ try {
2784
+ process.kill(pid, 0);
2785
+ return true;
2786
+ } catch {
2787
+ return false;
2788
+ }
2789
+ }
2790
+ function getWatchPid(projectPath) {
2791
+ try {
2792
+ const raw = readFileSync7(join9(projectPath, ".groundctl", "watch.pid"), "utf8").trim();
2793
+ return parseInt(raw) || null;
2794
+ } catch {
2795
+ return null;
2796
+ }
2797
+ }
2798
+ function httpsGet(url, timeoutMs = 5e3) {
2799
+ return new Promise((resolve3) => {
2800
+ const req = httpsRequest2(url, { method: "HEAD" }, (res) => {
2801
+ resolve3(res.statusCode ?? 0);
2802
+ });
2803
+ req.setTimeout(timeoutMs, () => {
2804
+ req.destroy();
2805
+ resolve3(0);
2806
+ });
2807
+ req.on("error", () => resolve3(0));
2808
+ req.end();
2809
+ });
2810
+ }
2811
+ function fetchNpmVersion(pkgName) {
2812
+ return new Promise((resolve3) => {
2813
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`;
2814
+ const req = httpsRequest2(url, { headers: { accept: "application/json" } }, (res) => {
2815
+ let data = "";
2816
+ res.on("data", (c) => {
2817
+ data += c.toString();
2818
+ });
2819
+ res.on("end", () => {
2820
+ try {
2821
+ const obj = JSON.parse(data);
2822
+ resolve3(obj.version ?? null);
2823
+ } catch {
2824
+ resolve3(null);
2825
+ }
2826
+ });
2827
+ });
2828
+ req.setTimeout(8e3, () => {
2829
+ req.destroy();
2830
+ resolve3(null);
2831
+ });
2832
+ req.on("error", () => resolve3(null));
2833
+ req.end();
2834
+ });
2835
+ }
2836
+ function compareVersions(a, b) {
2837
+ const pa = a.split(".").map(Number);
2838
+ const pb = b.split(".").map(Number);
2839
+ for (let i = 0; i < 3; i++) {
2840
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
2841
+ if (diff !== 0) return diff;
2842
+ }
2843
+ return 0;
2844
+ }
2845
+ async function doctorCommand() {
2846
+ const cwd = process.cwd();
2847
+ const current = pkg.version;
2848
+ console.log(chalk15.bold("\n groundctl doctor\n"));
2849
+ const [latest, proxyStatus] = await Promise.all([
2850
+ fetchNpmVersion("groundctl"),
2851
+ httpsGet("https://detect.groundctl.org/health")
2852
+ ]);
2853
+ if (!latest) {
2854
+ warn(`Version: ${current} (could not reach npm registry)`);
2855
+ } else if (compareVersions(current, latest) < 0) {
2856
+ warn(`Version: ${current} \u2014 update available: ${chalk15.cyan(latest)}`);
2857
+ info(`npm install -g groundctl@latest`);
2858
+ } else {
2859
+ ok(`Version: ${current} (up to date)`);
2860
+ }
2861
+ const pid = getWatchPid(cwd);
2862
+ if (!pid) {
2863
+ warn("Watch daemon: not started");
2864
+ info("groundctl watch --daemon");
2865
+ } else if (!processAlive2(pid)) {
2866
+ warn(`Watch daemon: PID ${pid} is no longer running`);
2867
+ info("groundctl watch --daemon");
2868
+ } else {
2869
+ ok(`Watch daemon: running (PID ${pid})`);
2870
+ }
2871
+ if (proxyStatus === 200) {
2872
+ ok("detect.groundctl.org: reachable");
2873
+ } else if (proxyStatus === 0) {
2874
+ warn("detect.groundctl.org: unreachable (no internet or proxy down)");
2875
+ info("Feature detection will fall back to ANTHROPIC_API_KEY or heuristic");
2876
+ } else {
2877
+ warn(`detect.groundctl.org: HTTP ${proxyStatus}`);
2878
+ }
2879
+ const groundctlDir = join9(cwd, ".groundctl");
2880
+ if (!existsSync7(groundctlDir)) {
2881
+ warn("Not initialized in this directory \u2014 run: groundctl init");
2882
+ } else {
2883
+ const db = await openDb();
2884
+ const groupCount = queryOne(db, "SELECT COUNT(*) as n FROM feature_groups")?.n ?? 0;
2885
+ const featureCount = queryOne(db, "SELECT COUNT(*) as n FROM features")?.n ?? 0;
2886
+ closeDb();
2887
+ if (featureCount === 0) {
2888
+ warn("No features tracked \u2014 run: groundctl init --import-from-git");
2889
+ } else {
2890
+ ok(`Features: ${featureCount} tracked`);
2891
+ }
2892
+ if (groupCount === 0) {
2893
+ warn("No feature groups configured");
2894
+ info(`groundctl add group -n "core" --label "Core"`);
2895
+ } else {
2896
+ ok(`Feature groups: ${groupCount} configured`);
2897
+ }
2898
+ }
2899
+ if (process.platform === "darwin") {
2900
+ if (existsSync7(LAUNCH_AGENT_PLIST)) {
2901
+ ok(`LaunchAgent: installed (${LAUNCH_AGENT_PLIST.replace(homedir5(), "~")})`);
2902
+ } else {
2903
+ warn("LaunchAgent: not installed (watch won't auto-start on login)");
2904
+ info("Re-run: groundctl init to install");
2905
+ }
2906
+ }
2907
+ console.log("");
2908
+ }
2909
+
2910
+ // src/index.ts
2911
+ import chalk16 from "chalk";
2912
+ var require3 = createRequire2(import.meta.url);
2913
+ var pkg2 = require3("../package.json");
2475
2914
  var program = new Command();
2476
- program.name("groundctl").description("The shared memory your agents and you actually need.").version(pkg.version);
2915
+ program.name("groundctl").description("The shared memory your agents and you actually need.").version(pkg2.version);
2477
2916
  program.command("init").description("Setup hooks + initial state for the current project").option("--import-from-git", "Bootstrap sessions and features from git history").action((opts) => initCommand({ importFromGit: opts.importFromGit }));
2478
- program.command("status").description("Show macro view of the product state").action(statusCommand);
2917
+ program.command("status").description("Show macro view of the product state").option("--detail", "Show full feature list with progress bars").option("--all", "Alias for --detail").action((opts) => statusCommand({ detail: opts.detail, all: opts.all }));
2479
2918
  program.command("claim <feature>").description("Reserve a feature for the current session").option("-s, --session <id>", "Session ID (auto-generated if omitted)").action(claimCommand);
2480
2919
  program.command("complete <feature>").description("Mark a feature as done and release the claim").action(completeCommand);
2481
2920
  program.command("sync").description("Regenerate PROJECT_STATE.md and AGENTS.md from SQLite").action(syncCommand);
2482
2921
  program.command("next").description("Show next available (unclaimed) feature").action(nextCommand);
2483
2922
  program.command("log").description("Show session timeline").option("-s, --session <id>", "Show details for a specific session").action(logCommand);
2484
- program.command("add <type>").description("Add a feature or session (type: feature, session)").option("-n, --name <name>", "Name").option("-p, --priority <priority>", "Priority (critical, high, medium, low)").option("-d, --description <desc>", "Description").option("--agent <agent>", "Agent type for sessions").option("--items <items>", "Comma-separated list of sub-items (features only)").option("--progress <N/N>", "Progress fraction e.g. 11/11 (features only)").action(addCommand);
2923
+ program.command("add <type>").description("Add a feature, group, or session (type: feature, group, session)").option("-n, --name <name>", "Name").option("-p, --priority <priority>", "Priority (critical, high, medium, low)").option("-d, --description <desc>", "Description").option("--agent <agent>", "Agent type for sessions").option("--items <items>", "Comma-separated list of sub-items (features only)").option("--progress <N/N>", "Progress fraction e.g. 11/11 (features only)").option("--label <label>", "Display label for groups").action(addCommand);
2485
2924
  program.command("ingest").description("Parse a transcript and write session data to SQLite").option("--source <source>", "Source agent (claude-code, codex)", "claude-code").option("--session-id <id>", "Session ID").option("--transcript <path>", "Path to transcript JSONL file (auto-detected if omitted)").option("--project-path <path>", "Project path (defaults to cwd)").option("--no-sync", "Skip regenerating markdown after ingest").action(
2486
2925
  (opts) => ingestCommand({
2487
2926
  source: opts.source,
@@ -2500,13 +2939,24 @@ program.command("watch").description("Watch for session end and auto-ingest tran
2500
2939
  projectPath: opts.projectPath
2501
2940
  })
2502
2941
  );
2503
- program.command("update <type> <name>").description("Update a feature's description, items, progress, or priority").option("-d, --description <desc>", "New description").option("--items <items>", "Comma-separated sub-items").option("--progress <N/N>", "Progress fraction e.g. 3/5").option("-p, --priority <priority>", "New priority").option("--status <status>", "New status (pending|in_progress|done|blocked)").action(
2942
+ program.command("update <type> <name>").description("Update a feature's fields (type: feature)").option("-d, --description <desc>", "New description").option("--items <items>", "Comma-separated sub-items").option("--progress <N/N>", "Progress fraction e.g. 3/5").option("-p, --priority <priority>", "New priority").option("--status <status>", "New status (pending|in_progress|done|blocked)").option("--group <group>", 'Assign to group by name or label (use "none" to ungroup)').action(
2504
2943
  (type, name, opts) => updateCommand(type, name, {
2505
2944
  description: opts.description,
2506
2945
  items: opts.items,
2507
2946
  progress: opts.progress,
2508
2947
  priority: opts.priority,
2509
- status: opts.status
2948
+ status: opts.status,
2949
+ group: opts.group
2510
2950
  })
2511
2951
  );
2952
+ program.command("doctor").description("Check groundctl health: version, daemon, proxy, groups").action(doctorCommand);
2953
+ program.on("command:*", (operands) => {
2954
+ const unknown = operands[0];
2955
+ console.error(chalk16.red(`
2956
+ Unknown command: ${unknown}
2957
+ `));
2958
+ console.error(` Run ${chalk16.cyan("groundctl --help")} to see available commands.
2959
+ `);
2960
+ process.exit(1);
2961
+ });
2512
2962
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundctl/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Always know what to build next.",
5
5
  "license": "MIT",
6
6
  "bin": {