@groundctl/cli 0.3.2 → 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 +981 -312
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,12 +2,15 @@
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
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, chmodSync, readFileSync as readFileSync3, appendFileSync } from "fs";
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 chalk from "chalk";
10
+ import { homedir as homedir2 } from "os";
11
+ import { spawn, execSync as execSync3 } from "child_process";
12
+ import { createInterface as createInterface2 } from "readline";
13
+ import chalk2 from "chalk";
11
14
 
12
15
  // src/storage/db.ts
13
16
  import initSqlJs from "sql.js";
@@ -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)]
@@ -352,8 +364,6 @@ function generateAgentsMd(db, projectName) {
352
364
 
353
365
  // src/ingest/git-import.ts
354
366
  import { execSync } from "child_process";
355
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
356
- import { join as join2 } from "path";
357
367
  function run(cmd, cwd) {
358
368
  try {
359
369
  return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
@@ -409,67 +419,10 @@ function parseGitLog(cwd) {
409
419
  }
410
420
  return commits.reverse();
411
421
  }
412
- function parseProjectStateMd(content) {
413
- const features = [];
414
- const lines = content.split("\n");
415
- let section = "";
416
- for (const line of lines) {
417
- const trimmed = line.trim();
418
- if (trimmed.startsWith("## ")) {
419
- section = trimmed.toLowerCase();
420
- continue;
421
- }
422
- if (section.includes("decision") || section.includes("session") || section.includes("debt") || section.includes("note")) continue;
423
- if (!trimmed.startsWith("- ") && !trimmed.startsWith("* ")) continue;
424
- const item = trimmed.slice(2).trim();
425
- if (!item || item.length < 3) continue;
426
- const name = item.split("(")[0].split("\u2192")[0].split("\u2014")[0].trim();
427
- if (!name || name.length < 3 || name.length > 80) continue;
428
- if (/^\d{4}-\d{2}-\d{2}/.test(name)) continue;
429
- if (name.split(" ").length > 8) continue;
430
- let status = "pending";
431
- let priority = "medium";
432
- if (section.includes("built") || section.includes("done") || section.includes("complete")) {
433
- status = "done";
434
- } else if (section.includes("claimed") || section.includes("in progress") || section.includes("current")) {
435
- status = "in_progress";
436
- } else if (section.includes("available") || section.includes("next")) {
437
- status = "pending";
438
- } else if (section.includes("blocked")) {
439
- status = "blocked";
440
- }
441
- if (/priority:\s*critical|critical\)/i.test(item)) priority = "critical";
442
- else if (/priority:\s*high|high\)/i.test(item)) priority = "high";
443
- else if (/priority:\s*low|low\)/i.test(item)) priority = "low";
444
- features.push({ name, status, priority });
445
- }
446
- return features;
447
- }
448
- function featureIdFromName(name) {
449
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
450
- }
451
422
  function importFromGit(db, projectPath) {
452
423
  let sessionsCreated = 0;
453
- let featuresImported = 0;
454
- const psMdPath = join2(projectPath, "PROJECT_STATE.md");
455
- if (existsSync2(psMdPath)) {
456
- const content = readFileSync2(psMdPath, "utf-8");
457
- const features = parseProjectStateMd(content);
458
- for (const feat of features) {
459
- const id = featureIdFromName(feat.name);
460
- if (!id) continue;
461
- const exists = queryOne(db, "SELECT id FROM features WHERE id = ?", [id]);
462
- if (!exists) {
463
- db.run(
464
- "INSERT INTO features (id, name, status, priority) VALUES (?, ?, ?, ?)",
465
- [id, feat.name, feat.status, feat.priority]
466
- );
467
- featuresImported++;
468
- }
469
- }
470
- }
471
424
  const commits = parseGitLog(projectPath);
472
- if (commits.length === 0) return { sessionsCreated, featuresImported };
425
+ if (commits.length === 0) return { sessionsCreated };
473
426
  const SESSION_GAP_MS = 4 * 60 * 60 * 1e3;
474
427
  const sessions = [];
475
428
  let currentSession = [];
@@ -523,7 +476,294 @@ function importFromGit(db, projectPath) {
523
476
  }
524
477
  }
525
478
  saveDb();
526
- return { sessionsCreated, featuresImported };
479
+ return { sessionsCreated };
480
+ }
481
+
482
+ // src/ingest/feature-detector.ts
483
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
484
+ import { join as join2 } from "path";
485
+ import { tmpdir } from "os";
486
+ import { execSync as execSync2 } from "child_process";
487
+ import { request as httpsRequest } from "https";
488
+ import { createInterface } from "readline";
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.";
494
+ function run2(cmd, cwd) {
495
+ try {
496
+ return execSync2(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
497
+ } catch {
498
+ return "";
499
+ }
500
+ }
501
+ function collectContextParts(projectPath) {
502
+ const gitLog = run2("git log --oneline --no-merges", projectPath);
503
+ const fileTree = run2(
504
+ [
505
+ "find . -type f",
506
+ "-not -path '*/node_modules/*'",
507
+ "-not -path '*/.git/*'",
508
+ "-not -path '*/dist/*'",
509
+ "-not -path '*/.groundctl/*'",
510
+ "-not -path '*/build/*'",
511
+ "-not -path '*/coverage/*'",
512
+ "-not -path '*/.venv/*'",
513
+ "-not -path '*/__pycache__/*'",
514
+ "-not -path '*/.pytest_cache/*'",
515
+ "-not -path '*/vendor/*'",
516
+ "-not -path '*/.next/*'",
517
+ "-not -name '*.lock'",
518
+ "-not -name '*.log'",
519
+ "-not -name '*.pyc'",
520
+ "| sort | head -120"
521
+ ].join(" "),
522
+ projectPath
523
+ );
524
+ const readmePath = join2(projectPath, "README.md");
525
+ const readme = existsSync2(readmePath) ? readFileSync2(readmePath, "utf-8").slice(0, 3e3) : void 0;
526
+ const psPath = join2(projectPath, "PROJECT_STATE.md");
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]);
564
+ }
565
+ if (!Array.isArray(obj.features)) throw new Error("Response missing 'features' array");
566
+ return normaliseFeatures(obj.features);
567
+ }
568
+ function httpsPost(url, body, extraHeaders) {
569
+ return new Promise((resolve3, reject) => {
570
+ const bodyStr = JSON.stringify(body);
571
+ const parsed = new URL(url);
572
+ const req = httpsRequest(
573
+ {
574
+ hostname: parsed.hostname,
575
+ path: parsed.pathname + parsed.search,
576
+ method: "POST",
577
+ headers: {
578
+ "content-type": "application/json",
579
+ "content-length": Buffer.byteLength(bodyStr),
580
+ "user-agent": USER_AGENT,
581
+ ...extraHeaders
582
+ }
583
+ },
584
+ (res) => {
585
+ let data = "";
586
+ res.on("data", (c) => {
587
+ data += c.toString();
588
+ });
589
+ res.on("end", () => {
590
+ if ((res.statusCode ?? 200) >= 400) {
591
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
592
+ } else {
593
+ resolve3(data);
594
+ }
595
+ });
596
+ }
597
+ );
598
+ req.setTimeout(15e3, () => {
599
+ req.destroy(new Error("Request timeout"));
600
+ });
601
+ req.on("error", reject);
602
+ req.write(bodyStr);
603
+ req.end();
604
+ });
605
+ }
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);
612
+ }
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;
655
+ }
656
+ function renderFeatureList(features) {
657
+ console.log(chalk.bold(`
658
+ Detected ${features.length} features:
659
+ `));
660
+ for (const f of features) {
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)}`);
665
+ }
666
+ console.log("");
667
+ }
668
+ function readLine(prompt) {
669
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
670
+ return new Promise((resolve3) => {
671
+ rl.question(prompt, (a) => {
672
+ rl.close();
673
+ resolve3(a.trim().toLowerCase());
674
+ });
675
+ });
676
+ }
677
+ async function editInEditor(features) {
678
+ const tmp = join2(tmpdir(), `groundctl-features-${Date.now()}.json`);
679
+ writeFileSync2(tmp, JSON.stringify({ features }, null, 2), "utf-8");
680
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
681
+ try {
682
+ execSync2(`${editor} "${tmp}"`, { stdio: "inherit" });
683
+ } catch {
684
+ return features;
685
+ }
686
+ try {
687
+ return parseFeatureJson(readFileSync2(tmp, "utf-8"));
688
+ } catch (e) {
689
+ console.log(chalk.red(` Parse error: ${e.message}`));
690
+ return null;
691
+ }
692
+ }
693
+ function importFeatures(db, features) {
694
+ db.run(`DELETE FROM features WHERE id NOT IN (SELECT DISTINCT feature_id FROM claims) AND status = 'pending'`);
695
+ for (const f of features) {
696
+ const status = f.status === "done" ? "done" : "pending";
697
+ if (!queryOne(db, "SELECT id FROM features WHERE id = ?", [f.name])) {
698
+ db.run(
699
+ "INSERT INTO features (id, name, status, priority, description) VALUES (?, ?, ?, ?, ?)",
700
+ [f.name, f.name, status, f.priority, f.description]
701
+ );
702
+ } else {
703
+ db.run(
704
+ "UPDATE features SET description = ?, priority = ?, updated_at = datetime('now') WHERE id = ?",
705
+ [f.description, f.priority, f.name]
706
+ );
707
+ }
708
+ }
709
+ saveDb();
710
+ }
711
+ async function detectAndImportFeatures(db, projectPath) {
712
+ process.stdout.write(chalk.gray(" Detecting features..."));
713
+ const parts = collectContextParts(projectPath);
714
+ const apiKey = process.env.ANTHROPIC_API_KEY;
715
+ let features = [];
716
+ let source = "";
717
+ try {
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
+ }
733
+ }
734
+ process.stdout.write("\r" + " ".repeat(30) + "\r");
735
+ if (features.length === 0) {
736
+ console.log(chalk.yellow(" No features detected \u2014 add them manually with groundctl add feature.\n"));
737
+ return false;
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)");
740
+ renderFeatureList(features);
741
+ console.log(chalk.gray(` Source: ${sourceLabel}
742
+ `));
743
+ let pending = features;
744
+ while (true) {
745
+ const answer = await readLine(chalk.bold(" Import these features? ") + chalk.gray("[y/n/edit] "));
746
+ if (answer === "y" || answer === "yes") {
747
+ importFeatures(db, pending);
748
+ console.log(chalk.green(`
749
+ \u2713 ${pending.length} features imported
750
+ `));
751
+ return true;
752
+ }
753
+ if (answer === "n" || answer === "no") {
754
+ console.log(chalk.gray(" Skipped.\n"));
755
+ return false;
756
+ }
757
+ if (answer === "e" || answer === "edit") {
758
+ const edited = await editInEditor(pending);
759
+ if (edited && edited.length > 0) {
760
+ pending = edited;
761
+ renderFeatureList(pending);
762
+ } else console.log(chalk.yellow(" No valid features after edit.\n"));
763
+ continue;
764
+ }
765
+ console.log(chalk.gray(" Please answer y, n, or edit."));
766
+ }
527
767
  }
528
768
 
529
769
  // src/commands/init.ts
@@ -560,56 +800,139 @@ groundctl ingest \\
560
800
  groundctl sync 2>/dev/null || true
561
801
  echo "--- groundctl: Product state updated ---"
562
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
+ }
563
887
  async function initCommand(options) {
564
888
  const cwd = process.cwd();
565
889
  const projectName = cwd.split("/").pop() ?? "unknown";
566
- console.log(chalk.bold(`
890
+ console.log(chalk2.bold(`
567
891
  groundctl init \u2014 ${projectName}
568
892
  `));
569
- console.log(chalk.gray(" Creating SQLite database..."));
893
+ console.log(chalk2.gray(" Creating SQLite database..."));
570
894
  const db = await openDb();
571
895
  if (options.importFromGit) {
572
896
  const isGitRepo = existsSync3(join3(cwd, ".git"));
573
897
  if (!isGitRepo) {
574
- console.log(chalk.yellow(" \u26A0 Not a git repo \u2014 skipping --import-from-git"));
898
+ console.log(chalk2.yellow(" \u26A0 Not a git repo \u2014 skipping --import-from-git"));
575
899
  } else {
576
- console.log(chalk.gray(" Importing from git history..."));
900
+ console.log(chalk2.gray(" Importing sessions from git history..."));
577
901
  const result = importFromGit(db, cwd);
578
902
  console.log(
579
- chalk.green(
580
- ` \u2713 Git import: ${result.sessionsCreated} sessions, ${result.featuresImported} features`
581
- )
903
+ chalk2.green(` \u2713 Git import: ${result.sessionsCreated} sessions`)
582
904
  );
905
+ await detectAndImportFeatures(db, cwd);
583
906
  }
584
907
  }
585
908
  const projectState = generateProjectState(db, projectName);
586
909
  const agentsMd = generateAgentsMd(db, projectName);
587
910
  closeDb();
588
- console.log(chalk.green(" \u2713 Database ready"));
911
+ console.log(chalk2.green(" \u2713 Database ready"));
589
912
  const claudeHooksDir = join3(cwd, ".claude", "hooks");
590
913
  if (!existsSync3(claudeHooksDir)) {
591
914
  mkdirSync2(claudeHooksDir, { recursive: true });
592
915
  }
593
- writeFileSync2(join3(claudeHooksDir, "pre-session.sh"), PRE_SESSION_HOOK);
916
+ writeFileSync3(join3(claudeHooksDir, "pre-session.sh"), PRE_SESSION_HOOK);
594
917
  chmodSync(join3(claudeHooksDir, "pre-session.sh"), 493);
595
- writeFileSync2(join3(claudeHooksDir, "post-session.sh"), POST_SESSION_HOOK);
918
+ writeFileSync3(join3(claudeHooksDir, "post-session.sh"), POST_SESSION_HOOK);
596
919
  chmodSync(join3(claudeHooksDir, "post-session.sh"), 493);
597
- console.log(chalk.green(" \u2713 Claude Code hooks installed"));
920
+ console.log(chalk2.green(" \u2713 Claude Code hooks installed"));
598
921
  const codexHooksDir = join3(cwd, ".codex", "hooks");
599
922
  if (!existsSync3(codexHooksDir)) {
600
923
  mkdirSync2(codexHooksDir, { recursive: true });
601
924
  }
602
925
  const codexPre = PRE_SESSION_HOOK.replace("Claude Code", "Codex");
603
926
  const codexPost = POST_SESSION_HOOK.replace("Claude Code", "Codex").replace("claude-code", "codex");
604
- writeFileSync2(join3(codexHooksDir, "pre-session.sh"), codexPre);
927
+ writeFileSync3(join3(codexHooksDir, "pre-session.sh"), codexPre);
605
928
  chmodSync(join3(codexHooksDir, "pre-session.sh"), 493);
606
- writeFileSync2(join3(codexHooksDir, "post-session.sh"), codexPost);
929
+ writeFileSync3(join3(codexHooksDir, "post-session.sh"), codexPost);
607
930
  chmodSync(join3(codexHooksDir, "post-session.sh"), 493);
608
- console.log(chalk.green(" \u2713 Codex hooks installed"));
609
- writeFileSync2(join3(cwd, "PROJECT_STATE.md"), projectState);
610
- writeFileSync2(join3(cwd, "AGENTS.md"), agentsMd);
611
- console.log(chalk.green(" \u2713 PROJECT_STATE.md generated"));
612
- console.log(chalk.green(" \u2713 AGENTS.md generated"));
931
+ console.log(chalk2.green(" \u2713 Codex hooks installed"));
932
+ writeFileSync3(join3(cwd, "PROJECT_STATE.md"), projectState);
933
+ writeFileSync3(join3(cwd, "AGENTS.md"), agentsMd);
934
+ console.log(chalk2.green(" \u2713 PROJECT_STATE.md generated"));
935
+ console.log(chalk2.green(" \u2713 AGENTS.md generated"));
613
936
  const gitignorePath = join3(cwd, ".gitignore");
614
937
  const gitignoreEntry = "\n# groundctl local state\n.groundctl/\n";
615
938
  if (existsSync3(gitignorePath)) {
@@ -618,80 +941,251 @@ groundctl init \u2014 ${projectName}
618
941
  appendFileSync(gitignorePath, gitignoreEntry);
619
942
  }
620
943
  }
621
- console.log(chalk.bold.green(`
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
+ }
975
+ console.log(chalk2.bold.green(`
622
976
  \u2713 groundctl initialized for ${projectName}
623
977
  `));
624
978
  if (!options.importFromGit) {
625
- console.log(chalk.gray(" Next steps:"));
626
- console.log(chalk.gray(" groundctl add feature -n 'my-feature' -p high"));
627
- console.log(chalk.gray(" groundctl status"));
628
- console.log(chalk.gray(" groundctl claim my-feature"));
629
- console.log(chalk.gray("\n Or bootstrap from git history:"));
630
- console.log(chalk.gray(" groundctl init --import-from-git\n"));
979
+ console.log(chalk2.gray(" Next steps:"));
980
+ console.log(chalk2.gray(" groundctl add feature -n 'my-feature' -p high"));
981
+ console.log(chalk2.gray(" groundctl status"));
982
+ console.log(chalk2.gray(" groundctl claim my-feature"));
983
+ console.log(chalk2.gray("\n Or bootstrap from git history:"));
984
+ console.log(chalk2.gray(" groundctl init --import-from-git\n"));
631
985
  } else {
632
- console.log(chalk.gray(" Next steps:"));
633
- console.log(chalk.gray(" groundctl status"));
634
- console.log(chalk.gray(" groundctl next\n"));
986
+ console.log(chalk2.gray(" Next steps:"));
987
+ console.log(chalk2.gray(" groundctl status"));
988
+ console.log(chalk2.gray(" groundctl next\n"));
635
989
  }
636
990
  }
637
991
 
638
992
  // src/commands/status.ts
639
- import chalk2 from "chalk";
640
- var BAR_W = 14;
993
+ import chalk3 from "chalk";
994
+ var AGG_BAR_W = 20;
995
+ var GRP_BAR_W = 20;
996
+ var FEAT_BAR_W = 14;
641
997
  var NAME_W = 22;
642
998
  var PROG_W = 6;
643
999
  function progressBar(done, total, width) {
644
- if (total <= 0) return chalk2.gray("\u2591".repeat(width));
1000
+ if (total <= 0) return chalk3.gray("\u2591".repeat(width));
645
1001
  const filled = Math.min(width, Math.round(done / total * width));
646
- return chalk2.green("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(width - filled));
1002
+ return chalk3.green("\u2588".repeat(filled)) + chalk3.gray("\u2591".repeat(width - filled));
647
1003
  }
648
- function featureBar(status, progressDone, progressTotal) {
649
- if (progressTotal != null && progressTotal > 0) {
650
- return progressBar(progressDone ?? 0, progressTotal, BAR_W);
651
- }
1004
+ function featureBar(status, pd, pt, width = FEAT_BAR_W) {
1005
+ if (pt != null && pt > 0) return progressBar(pd ?? 0, pt, width);
652
1006
  switch (status) {
653
1007
  case "done":
654
- return progressBar(1, 1, BAR_W);
1008
+ return progressBar(1, 1, width);
655
1009
  case "in_progress":
656
- return progressBar(1, 2, BAR_W);
1010
+ return progressBar(1, 2, width);
657
1011
  case "blocked":
658
- return chalk2.red("\u2591".repeat(BAR_W));
1012
+ return chalk3.red("\u2591".repeat(width));
659
1013
  default:
660
- return chalk2.gray("\u2591".repeat(BAR_W));
661
- }
662
- }
663
- function featureProgress(progressDone, progressTotal) {
664
- if (progressDone != null && progressTotal != null) {
665
- return `${progressDone}/${progressTotal}`;
1014
+ return chalk3.gray("\u2591".repeat(width));
666
1015
  }
667
- return "";
668
1016
  }
669
- function wrapItems(itemsCsv, maxWidth) {
670
- 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);
671
1019
  const lines = [];
672
- let current = "";
1020
+ let cur = "";
673
1021
  for (const item of items) {
674
- const next = current ? `${current} \xB7 ${item}` : item;
675
- if (next.length > maxWidth && current.length > 0) {
676
- lines.push(current);
677
- current = item;
678
- } else {
679
- current = next;
680
- }
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;
681
1027
  }
682
- if (current) lines.push(current);
1028
+ if (cur) lines.push(cur);
683
1029
  return lines;
684
1030
  }
685
- function timeSince(isoDate) {
686
- const then = (/* @__PURE__ */ new Date(isoDate + "Z")).getTime();
687
- const ms = Date.now() - then;
688
- const mins = Math.floor(ms / 6e4);
689
- if (mins < 60) return `${mins}m`;
690
- const h = Math.floor(mins / 60);
691
- const m = mins % 60;
692
- 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") : ""}`;
693
1037
  }
694
- async function statusCommand() {
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" : ""})`)
1045
+ );
1046
+ console.log("");
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`)}`);
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) {
1144
+ const total = features.length;
1145
+ const done = features.filter((f) => f.status === "done").length;
1146
+ const inProg = features.filter((f) => f.status === "in_progress").length;
1147
+ const blocked = features.filter((f) => f.status === "blocked").length;
1148
+ const pct = Math.round(done / total * 100);
1149
+ console.log("");
1150
+ console.log(
1151
+ chalk3.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk3.gray(` (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`)
1152
+ );
1153
+ console.log("");
1154
+ let aggSuffix = chalk3.white(` ${done}/${total} done`);
1155
+ if (inProg > 0) aggSuffix += chalk3.yellow(` ${inProg} in progress`);
1156
+ if (blocked > 0) aggSuffix += chalk3.red(` ${blocked} blocked`);
1157
+ console.log(` Features ${progressBar(done, total, AGG_BAR_W)}${aggSuffix}`);
1158
+ console.log("");
1159
+ const nameW = Math.min(NAME_W, Math.max(12, ...features.map((f) => f.name.length)));
1160
+ const contIndent = " ".repeat(4 + nameW + 1);
1161
+ const itemsMaxW = Math.max(40, 76 - contIndent.length);
1162
+ for (const f of features) {
1163
+ const isDone = f.status === "done";
1164
+ const isActive = f.status === "in_progress";
1165
+ const isBlocked = f.status === "blocked";
1166
+ const icon = isDone ? "\u2713" : isActive ? "\u25CF" : isBlocked ? "\u2717" : "\u25CB";
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;
1169
+ const nameRaw = f.name.slice(0, nameW).padEnd(nameW);
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 = "";
1175
+ if (isActive && f.claimed_session) {
1176
+ const el = f.claimed_at ? timeSince(f.claimed_at) : "";
1177
+ claimed = chalk3.yellow(` \u2192 ${f.claimed_session}${el ? ` (${el})` : ""}`);
1178
+ }
1179
+ console.log(` ${iconCh(icon)} ${nameCh(nameRaw)} ${bar2} ${prog}${descStr}${claimed}`);
1180
+ if (f.items) {
1181
+ for (const line of wrapItems(f.items, itemsMaxW)) {
1182
+ console.log(chalk3.dim(`${contIndent}${line}`));
1183
+ }
1184
+ }
1185
+ }
1186
+ console.log("");
1187
+ }
1188
+ async function statusCommand(opts) {
695
1189
  const db = await openDb();
696
1190
  const projectName = process.cwd().split("/").pop() ?? "unknown";
697
1191
  const features = query(
@@ -699,12 +1193,17 @@ async function statusCommand() {
699
1193
  `SELECT
700
1194
  f.id, f.name, f.status, f.priority,
701
1195
  f.description, f.progress_done, f.progress_total, f.items,
702
- c.session_id AS claimed_session,
703
- c.claimed_at AS claimed_at
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
704
1202
  FROM features f
705
- LEFT JOIN claims c
706
- ON c.feature_id = f.id AND c.released_at IS NULL
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
707
1205
  ORDER BY
1206
+ COALESCE(g.order_index, 9999),
708
1207
  CASE f.status
709
1208
  WHEN 'in_progress' THEN 0
710
1209
  WHEN 'blocked' THEN 1
@@ -712,80 +1211,40 @@ async function statusCommand() {
712
1211
  WHEN 'done' THEN 3
713
1212
  END,
714
1213
  CASE f.priority
715
- WHEN 'critical' THEN 0
716
- WHEN 'high' THEN 1
717
- WHEN 'medium' THEN 2
718
- WHEN 'low' THEN 3
1214
+ WHEN 'critical' THEN 0 WHEN 'high' THEN 1
1215
+ WHEN 'medium' THEN 2 WHEN 'low' THEN 3
719
1216
  END,
720
1217
  f.created_at`
721
1218
  );
1219
+ const groups = query(
1220
+ db,
1221
+ "SELECT id, name, label, order_index FROM feature_groups ORDER BY order_index"
1222
+ );
722
1223
  const sessionCount = queryOne(
723
1224
  db,
724
1225
  "SELECT COUNT(*) as count FROM sessions"
725
1226
  )?.count ?? 0;
726
1227
  closeDb();
727
- console.log("");
728
1228
  if (features.length === 0) {
729
- console.log(chalk2.bold(` ${projectName} \u2014 no features tracked yet
1229
+ console.log("");
1230
+ console.log(chalk3.bold(` ${projectName} \u2014 no features tracked yet
730
1231
  `));
731
- console.log(chalk2.gray(" Add features with: groundctl add feature -n 'my-feature'"));
732
- console.log(chalk2.gray(" Then run: groundctl status\n"));
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"));
733
1234
  return;
734
1235
  }
735
- const total = features.length;
736
- const done = features.filter((f) => f.status === "done").length;
737
- const inProg = features.filter((f) => f.status === "in_progress").length;
738
- const blocked = features.filter((f) => f.status === "blocked").length;
739
- const pct = Math.round(done / total * 100);
740
- console.log(
741
- chalk2.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk2.gray(` (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`)
742
- );
743
- console.log("");
744
- const aggBar = progressBar(done, total, 20);
745
- let aggSuffix = chalk2.white(` ${done}/${total} done`);
746
- if (inProg > 0) aggSuffix += chalk2.yellow(` ${inProg} in progress`);
747
- if (blocked > 0) aggSuffix += chalk2.red(` ${blocked} blocked`);
748
- console.log(` Features ${aggBar}${aggSuffix}`);
749
- console.log("");
750
- const maxNameLen = Math.min(NAME_W, Math.max(...features.map((f) => f.name.length)));
751
- const nameW = Math.max(maxNameLen, 12);
752
- const contIndent = " ".repeat(4 + nameW + 1);
753
- const itemsMaxW = Math.max(40, 76 - contIndent.length);
754
- for (const f of features) {
755
- const isDone = f.status === "done";
756
- const isActive = f.status === "in_progress";
757
- const isBlocked = f.status === "blocked";
758
- const icon = isDone ? "\u2713" : isActive ? "\u25CF" : isBlocked ? "\u2717" : "\u25CB";
759
- const iconChalk = isDone ? chalk2.green : isActive ? chalk2.yellow : isBlocked ? chalk2.red : chalk2.gray;
760
- const nameRaw = f.name.slice(0, nameW).padEnd(nameW);
761
- const nameChalk = isDone ? chalk2.dim : isActive ? chalk2.white : isBlocked ? chalk2.red : chalk2.gray;
762
- const pd = f.progress_done ?? null;
763
- const pt = f.progress_total ?? null;
764
- const bar2 = featureBar(f.status, pd, pt);
765
- const prog = featureProgress(pd, pt).padEnd(PROG_W);
766
- const descRaw = f.description ?? "";
767
- const descTrunc = descRaw.length > 38 ? descRaw.slice(0, 36) + "\u2026" : descRaw;
768
- const descStr = descTrunc ? chalk2.gray(` ${descTrunc}`) : "";
769
- let claimedStr = "";
770
- if (isActive && f.claimed_session) {
771
- const elapsed = f.claimed_at ? timeSince(f.claimed_at) : "";
772
- claimedStr = chalk2.yellow(` \u2192 ${f.claimed_session}${elapsed ? ` (${elapsed})` : ""}`);
773
- }
774
- console.log(
775
- ` ${iconChalk(icon)} ${nameChalk(nameRaw)} ${bar2} ${prog}${descStr}${claimedStr}`
776
- );
777
- if (f.items) {
778
- const lines = wrapItems(f.items, itemsMaxW);
779
- for (const line of lines) {
780
- console.log(chalk2.dim(`${contIndent}${line}`));
781
- }
782
- }
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);
783
1243
  }
784
- console.log("");
785
1244
  }
786
1245
 
787
1246
  // src/commands/claim.ts
788
- import chalk3 from "chalk";
1247
+ import chalk4 from "chalk";
789
1248
  import { randomUUID } from "crypto";
790
1249
  function findFeature(db, term) {
791
1250
  return queryOne(
@@ -822,15 +1281,15 @@ async function claimCommand(featureIdOrName, options) {
822
1281
  const db = await openDb();
823
1282
  const feature = findFeature(db, featureIdOrName);
824
1283
  if (!feature) {
825
- console.log(chalk3.red(`
1284
+ console.log(chalk4.red(`
826
1285
  Feature "${featureIdOrName}" not found.
827
1286
  `));
828
- console.log(chalk3.gray(" Add it with: groundctl add feature -n '" + featureIdOrName + "'"));
1287
+ console.log(chalk4.gray(" Add it with: groundctl add feature -n '" + featureIdOrName + "'"));
829
1288
  closeDb();
830
1289
  process.exit(1);
831
1290
  }
832
1291
  if (feature.status === "done") {
833
- console.log(chalk3.yellow(`
1292
+ console.log(chalk4.yellow(`
834
1293
  Feature "${feature.name}" is already done.
835
1294
  `));
836
1295
  closeDb();
@@ -844,7 +1303,7 @@ async function claimCommand(featureIdOrName, options) {
844
1303
  );
845
1304
  if (existingClaim) {
846
1305
  console.log(
847
- chalk3.red(`
1306
+ chalk4.red(`
848
1307
  Feature "${feature.name}" is already claimed by session ${existingClaim.session_id}`)
849
1308
  );
850
1309
  const alternatives = query(
@@ -859,9 +1318,9 @@ async function claimCommand(featureIdOrName, options) {
859
1318
  LIMIT 3`
860
1319
  );
861
1320
  if (alternatives.length > 0) {
862
- console.log(chalk3.gray("\n Available instead:"));
1321
+ console.log(chalk4.gray("\n Available instead:"));
863
1322
  for (const alt of alternatives) {
864
- console.log(chalk3.gray(` \u25CB ${alt.name}`));
1323
+ console.log(chalk4.gray(` \u25CB ${alt.name}`));
865
1324
  }
866
1325
  }
867
1326
  console.log("");
@@ -891,7 +1350,7 @@ async function claimCommand(featureIdOrName, options) {
891
1350
  saveDb();
892
1351
  closeDb();
893
1352
  console.log(
894
- chalk3.green(`
1353
+ chalk4.green(`
895
1354
  \u2713 Claimed "${feature.name}" \u2192 session ${sessionId}
896
1355
  `)
897
1356
  );
@@ -900,7 +1359,7 @@ async function completeCommand(featureIdOrName) {
900
1359
  const db = await openDb();
901
1360
  const feature = findFeature(db, featureIdOrName);
902
1361
  if (!feature) {
903
- console.log(chalk3.red(`
1362
+ console.log(chalk4.red(`
904
1363
  Feature "${featureIdOrName}" not found.
905
1364
  `));
906
1365
  closeDb();
@@ -916,15 +1375,15 @@ async function completeCommand(featureIdOrName) {
916
1375
  );
917
1376
  saveDb();
918
1377
  closeDb();
919
- console.log(chalk3.green(`
1378
+ console.log(chalk4.green(`
920
1379
  \u2713 Completed "${feature.name}"
921
1380
  `));
922
1381
  }
923
1382
 
924
1383
  // src/commands/sync.ts
925
- import { writeFileSync as writeFileSync3 } from "fs";
1384
+ import { writeFileSync as writeFileSync4 } from "fs";
926
1385
  import { join as join4 } from "path";
927
- import chalk4 from "chalk";
1386
+ import chalk5 from "chalk";
928
1387
  async function syncCommand(opts) {
929
1388
  const db = await openDb();
930
1389
  const projectName = process.cwd().split("/").pop() ?? "unknown";
@@ -932,16 +1391,16 @@ async function syncCommand(opts) {
932
1391
  const agentsMd = generateAgentsMd(db, projectName);
933
1392
  closeDb();
934
1393
  const cwd = process.cwd();
935
- writeFileSync3(join4(cwd, "PROJECT_STATE.md"), projectState);
936
- writeFileSync3(join4(cwd, "AGENTS.md"), agentsMd);
1394
+ writeFileSync4(join4(cwd, "PROJECT_STATE.md"), projectState);
1395
+ writeFileSync4(join4(cwd, "AGENTS.md"), agentsMd);
937
1396
  if (!opts?.silent) {
938
- console.log(chalk4.green("\n \u2713 PROJECT_STATE.md regenerated"));
939
- console.log(chalk4.green(" \u2713 AGENTS.md regenerated\n"));
1397
+ console.log(chalk5.green("\n \u2713 PROJECT_STATE.md regenerated"));
1398
+ console.log(chalk5.green(" \u2713 AGENTS.md regenerated\n"));
940
1399
  }
941
1400
  }
942
1401
 
943
1402
  // src/commands/next.ts
944
- import chalk5 from "chalk";
1403
+ import chalk6 from "chalk";
945
1404
  async function nextCommand() {
946
1405
  const db = await openDb();
947
1406
  const available = query(
@@ -961,26 +1420,26 @@ async function nextCommand() {
961
1420
  );
962
1421
  closeDb();
963
1422
  if (available.length === 0) {
964
- console.log(chalk5.yellow("\n No available features to claim.\n"));
1423
+ console.log(chalk6.yellow("\n No available features to claim.\n"));
965
1424
  return;
966
1425
  }
967
- console.log(chalk5.bold("\n Next available features:\n"));
1426
+ console.log(chalk6.bold("\n Next available features:\n"));
968
1427
  for (let i = 0; i < available.length; i++) {
969
1428
  const feat = available[i];
970
- const pColor = feat.priority === "critical" || feat.priority === "high" ? chalk5.red : chalk5.gray;
971
- const marker = i === 0 ? chalk5.green("\u2192") : " ";
1429
+ const pColor = feat.priority === "critical" || feat.priority === "high" ? chalk6.red : chalk6.gray;
1430
+ const marker = i === 0 ? chalk6.green("\u2192") : " ";
972
1431
  console.log(` ${marker} ${feat.name} ${pColor(`(${feat.priority})`)}`);
973
1432
  if (feat.description) {
974
- console.log(chalk5.gray(` ${feat.description}`));
1433
+ console.log(chalk6.gray(` ${feat.description}`));
975
1434
  }
976
1435
  }
977
- console.log(chalk5.gray(`
1436
+ console.log(chalk6.gray(`
978
1437
  Claim with: groundctl claim "${available[0].name}"
979
1438
  `));
980
1439
  }
981
1440
 
982
1441
  // src/commands/log.ts
983
- import chalk6 from "chalk";
1442
+ import chalk7 from "chalk";
984
1443
  async function logCommand(options) {
985
1444
  const db = await openDb();
986
1445
  if (options.session) {
@@ -989,18 +1448,18 @@ async function logCommand(options) {
989
1448
  `%${options.session}%`
990
1449
  ]);
991
1450
  if (!session) {
992
- console.log(chalk6.red(`
1451
+ console.log(chalk7.red(`
993
1452
  Session "${options.session}" not found.
994
1453
  `));
995
1454
  closeDb();
996
1455
  return;
997
1456
  }
998
- console.log(chalk6.bold(`
1457
+ console.log(chalk7.bold(`
999
1458
  Session ${session.id}`));
1000
- console.log(chalk6.gray(` Agent: ${session.agent}`));
1001
- console.log(chalk6.gray(` Started: ${session.started_at}`));
1459
+ console.log(chalk7.gray(` Agent: ${session.agent}`));
1460
+ console.log(chalk7.gray(` Started: ${session.started_at}`));
1002
1461
  if (session.ended_at) {
1003
- console.log(chalk6.gray(` Ended: ${session.ended_at}`));
1462
+ console.log(chalk7.gray(` Ended: ${session.ended_at}`));
1004
1463
  }
1005
1464
  if (session.summary) {
1006
1465
  console.log(`
@@ -1012,11 +1471,11 @@ async function logCommand(options) {
1012
1471
  [session.id]
1013
1472
  );
1014
1473
  if (decisions.length > 0) {
1015
- console.log(chalk6.bold("\n Decisions:"));
1474
+ console.log(chalk7.bold("\n Decisions:"));
1016
1475
  for (const d of decisions) {
1017
1476
  console.log(` \u2022 ${d.description}`);
1018
1477
  if (d.rationale) {
1019
- console.log(chalk6.gray(` ${d.rationale}`));
1478
+ console.log(chalk7.gray(` ${d.rationale}`));
1020
1479
  }
1021
1480
  }
1022
1481
  }
@@ -1026,10 +1485,10 @@ async function logCommand(options) {
1026
1485
  [session.id]
1027
1486
  );
1028
1487
  if (files.length > 0) {
1029
- console.log(chalk6.bold(`
1488
+ console.log(chalk7.bold(`
1030
1489
  Files modified (${files.length}):`));
1031
1490
  for (const f of files) {
1032
- const op = f.operation === "created" ? chalk6.green("+") : f.operation === "deleted" ? chalk6.red("-") : chalk6.yellow("~");
1491
+ const op = f.operation === "created" ? chalk7.green("+") : f.operation === "deleted" ? chalk7.red("-") : chalk7.yellow("~");
1033
1492
  console.log(` ${op} ${f.path} (${f.lines_changed} lines)`);
1034
1493
  }
1035
1494
  }
@@ -1037,18 +1496,18 @@ async function logCommand(options) {
1037
1496
  } else {
1038
1497
  const sessions = query(db, "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 20");
1039
1498
  if (sessions.length === 0) {
1040
- console.log(chalk6.yellow("\n No sessions recorded yet.\n"));
1499
+ console.log(chalk7.yellow("\n No sessions recorded yet.\n"));
1041
1500
  closeDb();
1042
1501
  return;
1043
1502
  }
1044
- console.log(chalk6.bold("\n Session timeline:\n"));
1503
+ console.log(chalk7.bold("\n Session timeline:\n"));
1045
1504
  for (const s of sessions) {
1046
- const status = s.ended_at ? chalk6.green("done") : chalk6.yellow("active");
1505
+ const status = s.ended_at ? chalk7.green("done") : chalk7.yellow("active");
1047
1506
  console.log(
1048
- ` ${chalk6.bold(s.id)} ${chalk6.gray(s.started_at)} ${status} ${chalk6.gray(s.agent)}`
1507
+ ` ${chalk7.bold(s.id)} ${chalk7.gray(s.started_at)} ${status} ${chalk7.gray(s.agent)}`
1049
1508
  );
1050
1509
  if (s.summary) {
1051
- console.log(chalk6.gray(` ${s.summary}`));
1510
+ console.log(chalk7.gray(` ${s.summary}`));
1052
1511
  }
1053
1512
  }
1054
1513
  console.log("");
@@ -1057,7 +1516,7 @@ async function logCommand(options) {
1057
1516
  }
1058
1517
 
1059
1518
  // src/commands/add.ts
1060
- import chalk7 from "chalk";
1519
+ import chalk8 from "chalk";
1061
1520
  import { randomUUID as randomUUID2 } from "crypto";
1062
1521
  function parseProgress(s) {
1063
1522
  const m = s.match(/^(\d+)\/(\d+)$/);
@@ -1068,7 +1527,7 @@ async function addCommand(type, options) {
1068
1527
  const db = await openDb();
1069
1528
  if (type === "feature") {
1070
1529
  if (!options.name) {
1071
- console.log(chalk7.red("\n --name is required for features.\n"));
1530
+ console.log(chalk8.red("\n --name is required for features.\n"));
1072
1531
  closeDb();
1073
1532
  process.exit(1);
1074
1533
  }
@@ -1081,9 +1540,7 @@ async function addCommand(type, options) {
1081
1540
  if (p) {
1082
1541
  progressDone = p.done;
1083
1542
  progressTotal = p.total;
1084
- } else {
1085
- console.log(chalk7.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)`));
1086
- }
1543
+ } else console.log(chalk8.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)`));
1087
1544
  }
1088
1545
  const items = options.items ? options.items.split(",").map((s) => s.trim()).filter(Boolean).join(",") : null;
1089
1546
  db.run(
@@ -1105,9 +1562,39 @@ async function addCommand(type, options) {
1105
1562
  const extras = [];
1106
1563
  if (progressDone !== null) extras.push(`${progressDone}/${progressTotal}`);
1107
1564
  if (items) extras.push(`${items.split(",").length} items`);
1108
- const suffix = extras.length ? chalk7.gray(` \u2014 ${extras.join(", ")}`) : "";
1109
- console.log(chalk7.green(`
1565
+ const suffix = extras.length ? chalk8.gray(` \u2014 ${extras.join(", ")}`) : "";
1566
+ console.log(chalk8.green(`
1110
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})
1111
1598
  `));
1112
1599
  } else if (type === "session") {
1113
1600
  const id = options.name ?? randomUUID2().slice(0, 8);
@@ -1115,12 +1602,12 @@ async function addCommand(type, options) {
1115
1602
  db.run("INSERT INTO sessions (id, agent) VALUES (?, ?)", [id, agent]);
1116
1603
  saveDb();
1117
1604
  closeDb();
1118
- console.log(chalk7.green(`
1605
+ console.log(chalk8.green(`
1119
1606
  \u2713 Session created: ${id} (${agent})
1120
1607
  `));
1121
1608
  } else {
1122
- console.log(chalk7.red(`
1123
- Unknown type "${type}". Use "feature" or "session".
1609
+ console.log(chalk8.red(`
1610
+ Unknown type "${type}". Use "feature", "group", or "session".
1124
1611
  `));
1125
1612
  closeDb();
1126
1613
  process.exit(1);
@@ -1130,8 +1617,8 @@ async function addCommand(type, options) {
1130
1617
  // src/commands/ingest.ts
1131
1618
  import { existsSync as existsSync4, readdirSync } from "fs";
1132
1619
  import { join as join5, resolve } from "path";
1133
- import { homedir as homedir2 } from "os";
1134
- import chalk8 from "chalk";
1620
+ import { homedir as homedir3 } from "os";
1621
+ import chalk9 from "chalk";
1135
1622
 
1136
1623
  // src/ingest/claude-parser.ts
1137
1624
  import { readFileSync as readFileSync4 } from "fs";
@@ -1366,7 +1853,7 @@ function claudeEncode(p) {
1366
1853
  return p.replace(/[^a-zA-Z0-9]/g, "-");
1367
1854
  }
1368
1855
  function findLatestTranscript(projectPath) {
1369
- const projectsDir = join5(homedir2(), ".claude", "projects");
1856
+ const projectsDir = join5(homedir3(), ".claude", "projects");
1370
1857
  if (!existsSync4(projectsDir)) return null;
1371
1858
  let transcriptDir = null;
1372
1859
  const projectKey = claudeEncode(projectPath);
@@ -1401,11 +1888,11 @@ async function ingestCommand(options) {
1401
1888
  transcriptPath = findLatestTranscript(projectPath) ?? void 0;
1402
1889
  }
1403
1890
  if (!transcriptPath || !existsSync4(transcriptPath)) {
1404
- console.log(chalk8.yellow("\n No transcript found. Skipping ingest.\n"));
1891
+ console.log(chalk9.yellow("\n No transcript found. Skipping ingest.\n"));
1405
1892
  if (!options.noSync) await syncCommand();
1406
1893
  return;
1407
1894
  }
1408
- console.log(chalk8.gray(`
1895
+ console.log(chalk9.gray(`
1409
1896
  Parsing transcript: ${transcriptPath.split("/").slice(-2).join("/")}`));
1410
1897
  const parsed = parseTranscript(transcriptPath, options.sessionId ?? "auto", projectPath);
1411
1898
  const db = await openDb();
@@ -1455,16 +1942,16 @@ async function ingestCommand(options) {
1455
1942
  saveDb();
1456
1943
  closeDb();
1457
1944
  console.log(
1458
- chalk8.green(
1945
+ chalk9.green(
1459
1946
  ` \u2713 Ingested session ${sessionId}: ${newFiles} files, ${parsed.commits.length} commits, ${newDecisions} decisions`
1460
1947
  )
1461
1948
  );
1462
1949
  if (parsed.decisions.length > 0 && newDecisions > 0) {
1463
- console.log(chalk8.gray(`
1950
+ console.log(chalk9.gray(`
1464
1951
  Decisions captured:`));
1465
1952
  for (const d of parsed.decisions.slice(0, 5)) {
1466
- const conf = d.confidence === "low" ? chalk8.gray(" (low confidence)") : "";
1467
- console.log(chalk8.gray(` \u2022 ${d.description.slice(0, 80)}${conf}`));
1953
+ const conf = d.confidence === "low" ? chalk9.gray(" (low confidence)") : "";
1954
+ console.log(chalk9.gray(` \u2022 ${d.description.slice(0, 80)}${conf}`));
1468
1955
  }
1469
1956
  }
1470
1957
  if (!options.noSync) {
@@ -1474,9 +1961,9 @@ async function ingestCommand(options) {
1474
1961
  }
1475
1962
 
1476
1963
  // src/commands/report.ts
1477
- import { writeFileSync as writeFileSync4 } from "fs";
1964
+ import { writeFileSync as writeFileSync5 } from "fs";
1478
1965
  import { join as join6 } from "path";
1479
- import chalk9 from "chalk";
1966
+ import chalk10 from "chalk";
1480
1967
  function formatDuration(start, end) {
1481
1968
  if (!end) return "ongoing";
1482
1969
  const startMs = new Date(start).getTime();
@@ -1571,7 +2058,7 @@ async function reportCommand(options) {
1571
2058
  [options.session, `%${options.session}%`]
1572
2059
  );
1573
2060
  if (!s) {
1574
- console.log(chalk9.red(`
2061
+ console.log(chalk10.red(`
1575
2062
  Session "${options.session}" not found.
1576
2063
  `));
1577
2064
  closeDb();
@@ -1584,7 +2071,7 @@ async function reportCommand(options) {
1584
2071
  "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 1"
1585
2072
  );
1586
2073
  if (!s) {
1587
- console.log(chalk9.yellow("\n No sessions found. Run groundctl init first.\n"));
2074
+ console.log(chalk10.yellow("\n No sessions found. Run groundctl init first.\n"));
1588
2075
  closeDb();
1589
2076
  return;
1590
2077
  }
@@ -1626,8 +2113,8 @@ async function reportCommand(options) {
1626
2113
  }
1627
2114
  closeDb();
1628
2115
  const outPath2 = join6(cwd, "SESSION_HISTORY.md");
1629
- writeFileSync4(outPath2, fullReport);
1630
- console.log(chalk9.green(`
2116
+ writeFileSync5(outPath2, fullReport);
2117
+ console.log(chalk10.green(`
1631
2118
  \u2713 SESSION_HISTORY.md written (${sessions.length} sessions)
1632
2119
  `));
1633
2120
  return;
@@ -1653,16 +2140,16 @@ async function reportCommand(options) {
1653
2140
  activeClaims
1654
2141
  );
1655
2142
  const outPath = join6(cwd, "SESSION_REPORT.md");
1656
- writeFileSync4(outPath, report);
1657
- console.log(chalk9.green(`
2143
+ writeFileSync5(outPath, report);
2144
+ console.log(chalk10.green(`
1658
2145
  \u2713 SESSION_REPORT.md written (session ${session.id})
1659
2146
  `));
1660
- console.log(chalk9.gray(` ${files.length} files \xB7 ${decisions.length} arch log entries \xB7 ${completedFeatures.length} features completed`));
2147
+ console.log(chalk10.gray(` ${files.length} files \xB7 ${decisions.length} arch log entries \xB7 ${completedFeatures.length} features completed`));
1661
2148
  console.log("");
1662
2149
  }
1663
2150
 
1664
2151
  // src/commands/health.ts
1665
- import chalk10 from "chalk";
2152
+ import chalk11 from "chalk";
1666
2153
  async function healthCommand() {
1667
2154
  const db = await openDb();
1668
2155
  const projectName = process.cwd().split("/").pop() ?? "unknown";
@@ -1713,32 +2200,32 @@ async function healthCommand() {
1713
2200
  closeDb();
1714
2201
  const totalScore = featureScore + testScore + decisionScore + claimScore + deployScore;
1715
2202
  console.log("");
1716
- console.log(chalk10.bold(` ${projectName} \u2014 Health Score: ${totalScore}/100
2203
+ console.log(chalk11.bold(` ${projectName} \u2014 Health Score: ${totalScore}/100
1717
2204
  `));
1718
- const featureColor = featurePct >= 0.7 ? chalk10.green : featurePct >= 0.4 ? chalk10.yellow : chalk10.red;
2205
+ const featureColor = featurePct >= 0.7 ? chalk11.green : featurePct >= 0.4 ? chalk11.yellow : chalk11.red;
1719
2206
  const featureMark = featurePct >= 0.4 ? "\u2705" : "\u26A0\uFE0F ";
1720
2207
  console.log(
1721
- ` ${featureMark} Features ${String(counts.done).padStart(2)}/${total} complete` + featureColor(` (${Math.round(featurePct * 100)}%)`) + chalk10.gray(` +${featureScore}pts`)
2208
+ ` ${featureMark} Features ${String(counts.done).padStart(2)}/${total} complete` + featureColor(` (${Math.round(featurePct * 100)}%)`) + chalk11.gray(` +${featureScore}pts`)
1722
2209
  );
1723
2210
  const testMark = testFiles > 0 ? "\u2705" : "\u26A0\uFE0F ";
1724
- const testColor = testFiles > 0 ? chalk10.green : chalk10.red;
2211
+ const testColor = testFiles > 0 ? chalk11.green : chalk11.red;
1725
2212
  console.log(
1726
- ` ${testMark} Tests ${testColor(String(testFiles) + " test files")}` + (testFiles === 0 ? chalk10.red(" (-20pts)") : chalk10.gray(` +${testScore}pts`))
2213
+ ` ${testMark} Tests ${testColor(String(testFiles) + " test files")}` + (testFiles === 0 ? chalk11.red(" (-20pts)") : chalk11.gray(` +${testScore}pts`))
1727
2214
  );
1728
2215
  const decMark = decisionCount > 0 ? "\u2705" : "\u26A0\uFE0F ";
1729
- const decColor = decisionCount > 0 ? chalk10.green : chalk10.yellow;
2216
+ const decColor = decisionCount > 0 ? chalk11.green : chalk11.yellow;
1730
2217
  console.log(
1731
- ` ${decMark} Arch log ${decColor(decisionCount + " entries")}` + chalk10.gray(` +${decisionScore}pts`)
2218
+ ` ${decMark} Arch log ${decColor(decisionCount + " entries")}` + chalk11.gray(` +${decisionScore}pts`)
1732
2219
  );
1733
2220
  const claimMark = staleClaims === 0 ? "\u2705" : "\u26A0\uFE0F ";
1734
- const claimColor = staleClaims === 0 ? chalk10.green : chalk10.red;
2221
+ const claimColor = staleClaims === 0 ? chalk11.green : chalk11.red;
1735
2222
  console.log(
1736
- ` ${claimMark} Claims ${claimColor(staleClaims > 0 ? staleClaims + " stale (>24h)" : "0 stale")}` + chalk10.gray(` +${claimScore}pts`)
2223
+ ` ${claimMark} Claims ${claimColor(staleClaims > 0 ? staleClaims + " stale (>24h)" : "0 stale")}` + chalk11.gray(` +${claimScore}pts`)
1737
2224
  );
1738
2225
  const deployMark = deployScore > 0 ? "\u2705" : "\u26A0\uFE0F ";
1739
- const deployLabel = deployScore > 0 ? chalk10.green("detected") : chalk10.gray("not detected");
2226
+ const deployLabel = deployScore > 0 ? chalk11.green("detected") : chalk11.gray("not detected");
1740
2227
  console.log(
1741
- ` ${deployMark} Deploy ${deployLabel}` + (deployScore > 0 ? chalk10.gray(` +${deployScore}pts`) : chalk10.gray(" +0pts"))
2228
+ ` ${deployMark} Deploy ${deployLabel}` + (deployScore > 0 ? chalk11.gray(` +${deployScore}pts`) : chalk11.gray(" +0pts"))
1742
2229
  );
1743
2230
  console.log("");
1744
2231
  const recommendations = [];
@@ -1747,9 +2234,9 @@ async function healthCommand() {
1747
2234
  if (decisionCount === 0) recommendations.push("Log architecture decisions during sessions so agents understand the why.");
1748
2235
  if (featurePct < 0.5 && total > 0) recommendations.push(`${counts.pending} features pending \u2014 run groundctl next to pick one.`);
1749
2236
  if (recommendations.length > 0) {
1750
- console.log(chalk10.bold(" Recommendations:"));
2237
+ console.log(chalk11.bold(" Recommendations:"));
1751
2238
  for (const r of recommendations) {
1752
- console.log(chalk10.yellow(` \u2192 ${r}`));
2239
+ console.log(chalk11.yellow(` \u2192 ${r}`));
1753
2240
  }
1754
2241
  console.log("");
1755
2242
  }
@@ -1760,7 +2247,7 @@ import { createServer } from "http";
1760
2247
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
1761
2248
  import { join as join7, dirname as dirname2 } from "path";
1762
2249
  import { exec } from "child_process";
1763
- import chalk11 from "chalk";
2250
+ import chalk12 from "chalk";
1764
2251
  import initSqlJs2 from "sql.js";
1765
2252
  function findDbPath(startDir = process.cwd()) {
1766
2253
  let dir = startDir;
@@ -1923,9 +2410,9 @@ async function dashboardCommand(options) {
1923
2410
  }
1924
2411
  });
1925
2412
  server.listen(port, "127.0.0.1", () => {
1926
- console.log(chalk11.bold(`
1927
- groundctl dashboard \u2192 `) + chalk11.blue(`http://localhost:${port}`) + "\n");
1928
- console.log(chalk11.gray(" Auto-refreshes every 10s. Press Ctrl+C to stop.\n"));
2413
+ console.log(chalk12.bold(`
2414
+ groundctl dashboard \u2192 `) + chalk12.blue(`http://localhost:${port}`) + "\n");
2415
+ console.log(chalk12.gray(" Auto-refreshes every 10s. Press Ctrl+C to stop.\n"));
1929
2416
  exec(`open http://localhost:${port} 2>/dev/null || xdg-open http://localhost:${port} 2>/dev/null || true`);
1930
2417
  });
1931
2418
  await new Promise((_, reject) => {
@@ -1938,22 +2425,22 @@ import {
1938
2425
  existsSync as existsSync6,
1939
2426
  readdirSync as readdirSync2,
1940
2427
  statSync,
1941
- writeFileSync as writeFileSync5,
2428
+ writeFileSync as writeFileSync6,
1942
2429
  readFileSync as readFileSync6,
1943
2430
  mkdirSync as mkdirSync3,
1944
2431
  watch as fsWatch
1945
2432
  } from "fs";
1946
2433
  import { join as join8, resolve as resolve2 } from "path";
1947
- import { homedir as homedir3 } from "os";
1948
- import { spawn } from "child_process";
1949
- import chalk12 from "chalk";
2434
+ import { homedir as homedir4 } from "os";
2435
+ import { spawn as spawn2 } from "child_process";
2436
+ import chalk13 from "chalk";
1950
2437
  var DEBOUNCE_MS = 8e3;
1951
2438
  var DIR_POLL_MS = 5e3;
1952
2439
  function claudeEncode2(p) {
1953
2440
  return p.replace(/[^a-zA-Z0-9]/g, "-");
1954
2441
  }
1955
2442
  function findTranscriptDir(projectPath) {
1956
- const projectsDir = join8(homedir3(), ".claude", "projects");
2443
+ const projectsDir = join8(homedir4(), ".claude", "projects");
1957
2444
  if (!existsSync6(projectsDir)) return null;
1958
2445
  const projectKey = claudeEncode2(projectPath);
1959
2446
  const direct = join8(projectsDir, projectKey);
@@ -1978,7 +2465,7 @@ function fileSize(p) {
1978
2465
  function writePidFile(groundctlDir, pid) {
1979
2466
  try {
1980
2467
  mkdirSync3(groundctlDir, { recursive: true });
1981
- writeFileSync5(join8(groundctlDir, "watch.pid"), String(pid), "utf8");
2468
+ writeFileSync6(join8(groundctlDir, "watch.pid"), String(pid), "utf8");
1982
2469
  } catch {
1983
2470
  }
1984
2471
  }
@@ -2001,8 +2488,8 @@ function processAlive(pid) {
2001
2488
  async function runIngest(transcriptPath, projectPath) {
2002
2489
  const filename = transcriptPath.split("/").slice(-2).join("/");
2003
2490
  console.log(
2004
- chalk12.gray(`
2005
- [${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] `) + chalk12.cyan(`Transcript stable \u2192 ingesting ${filename}`)
2491
+ chalk13.gray(`
2492
+ [${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] `) + chalk13.cyan(`Transcript stable \u2192 ingesting ${filename}`)
2006
2493
  );
2007
2494
  try {
2008
2495
  const parsed = parseTranscript(transcriptPath, "auto", projectPath);
@@ -2058,11 +2545,11 @@ async function runIngest(transcriptPath, projectPath) {
2058
2545
  if (parsed.commits.length > 0) parts.push(`${parsed.commits.length} commit${parsed.commits.length !== 1 ? "s" : ""}`);
2059
2546
  if (newDecisions > 0) parts.push(`${newDecisions} decision${newDecisions !== 1 ? "s" : ""} captured`);
2060
2547
  const summary = parts.length > 0 ? parts.join(", ") : "no new data";
2061
- console.log(chalk12.green(` \u2713 Session ingested \u2014 ${summary}`));
2548
+ console.log(chalk13.green(` \u2713 Session ingested \u2014 ${summary}`));
2062
2549
  await syncCommand({ silent: true });
2063
- console.log(chalk12.gray(" \u21B3 PROJECT_STATE.md + AGENTS.md updated"));
2550
+ console.log(chalk13.gray(" \u21B3 PROJECT_STATE.md + AGENTS.md updated"));
2064
2551
  } catch (err) {
2065
- console.log(chalk12.red(` \u2717 Ingest failed: ${err.message}`));
2552
+ console.log(chalk13.red(` \u2717 Ingest failed: ${err.message}`));
2066
2553
  }
2067
2554
  }
2068
2555
  function startWatcher(transcriptDir, projectPath) {
@@ -2110,47 +2597,47 @@ function startWatcher(transcriptDir, projectPath) {
2110
2597
  schedule(fp);
2111
2598
  }
2112
2599
  });
2113
- console.log(chalk12.bold("\n groundctl watch") + chalk12.gray(" \u2014 auto-ingest on session end\n"));
2600
+ console.log(chalk13.bold("\n groundctl watch") + chalk13.gray(" \u2014 auto-ingest on session end\n"));
2114
2601
  console.log(
2115
- chalk12.gray(" Watching: ") + chalk12.blue(transcriptDir.replace(homedir3(), "~"))
2602
+ chalk13.gray(" Watching: ") + chalk13.blue(transcriptDir.replace(homedir4(), "~"))
2116
2603
  );
2117
- console.log(chalk12.gray(" Stability threshold: ") + chalk12.white(`${DEBOUNCE_MS / 1e3}s`));
2118
- console.log(chalk12.gray(" Press Ctrl+C to stop.\n"));
2604
+ console.log(chalk13.gray(" Stability threshold: ") + chalk13.white(`${DEBOUNCE_MS / 1e3}s`));
2605
+ console.log(chalk13.gray(" Press Ctrl+C to stop.\n"));
2119
2606
  }
2120
2607
  async function watchCommand(options) {
2121
2608
  const projectPath = options.projectPath ? resolve2(options.projectPath) : process.cwd();
2122
2609
  if (options.daemon) {
2123
2610
  const args = [process.argv[1], "watch", "--project-path", projectPath];
2124
- const child = spawn(process.execPath, args, {
2611
+ const child = spawn2(process.execPath, args, {
2125
2612
  detached: true,
2126
2613
  stdio: "ignore"
2127
2614
  });
2128
2615
  child.unref();
2129
2616
  const groundctlDir2 = join8(projectPath, ".groundctl");
2130
2617
  writePidFile(groundctlDir2, child.pid);
2131
- console.log(chalk12.green(`
2618
+ console.log(chalk13.green(`
2132
2619
  \u2713 groundctl watch running in background (PID ${child.pid})`));
2133
- console.log(chalk12.gray(` PID saved to .groundctl/watch.pid`));
2134
- console.log(chalk12.gray(` To stop: kill ${child.pid}
2620
+ console.log(chalk13.gray(` PID saved to .groundctl/watch.pid`));
2621
+ console.log(chalk13.gray(` To stop: kill ${child.pid}
2135
2622
  `));
2136
2623
  process.exit(0);
2137
2624
  }
2138
2625
  const groundctlDir = join8(projectPath, ".groundctl");
2139
2626
  const existingPid = readPidFile(groundctlDir);
2140
2627
  if (existingPid && processAlive(existingPid)) {
2141
- console.log(chalk12.yellow(`
2628
+ console.log(chalk13.yellow(`
2142
2629
  \u26A0 A watcher is already running (PID ${existingPid}).`));
2143
- console.log(chalk12.gray(` To stop it: kill ${existingPid}
2630
+ console.log(chalk13.gray(` To stop it: kill ${existingPid}
2144
2631
  `));
2145
2632
  process.exit(1);
2146
2633
  }
2147
2634
  let transcriptDir = findTranscriptDir(projectPath);
2148
2635
  if (!transcriptDir) {
2149
- console.log(chalk12.bold("\n groundctl watch\n"));
2636
+ console.log(chalk13.bold("\n groundctl watch\n"));
2150
2637
  console.log(
2151
- chalk12.yellow(" No Claude Code transcript directory found for this project yet.")
2638
+ chalk13.yellow(" No Claude Code transcript directory found for this project yet.")
2152
2639
  );
2153
- console.log(chalk12.gray(" Waiting for first session to start...\n"));
2640
+ console.log(chalk13.gray(" Waiting for first session to start...\n"));
2154
2641
  await new Promise((resolve3) => {
2155
2642
  const interval = setInterval(() => {
2156
2643
  const dir = findTranscriptDir(projectPath);
@@ -2165,7 +2652,7 @@ async function watchCommand(options) {
2165
2652
  startWatcher(transcriptDir, projectPath);
2166
2653
  await new Promise(() => {
2167
2654
  process.on("SIGINT", () => {
2168
- console.log(chalk12.gray("\n Watcher stopped.\n"));
2655
+ console.log(chalk13.gray("\n Watcher stopped.\n"));
2169
2656
  process.exit(0);
2170
2657
  });
2171
2658
  process.on("SIGTERM", () => {
@@ -2175,7 +2662,7 @@ async function watchCommand(options) {
2175
2662
  }
2176
2663
 
2177
2664
  // src/commands/update.ts
2178
- import chalk13 from "chalk";
2665
+ import chalk14 from "chalk";
2179
2666
  function parseProgress2(s) {
2180
2667
  const m = s.match(/^(\d+)\/(\d+)$/);
2181
2668
  if (!m) return null;
@@ -2183,7 +2670,7 @@ function parseProgress2(s) {
2183
2670
  }
2184
2671
  async function updateCommand(type, nameOrId, options) {
2185
2672
  if (type !== "feature") {
2186
- console.log(chalk13.red(`
2673
+ console.log(chalk14.red(`
2187
2674
  Unknown type "${type}". Use "feature".
2188
2675
  `));
2189
2676
  process.exit(1);
@@ -2199,7 +2686,7 @@ async function updateCommand(type, nameOrId, options) {
2199
2686
  [nameOrId, `%${nameOrId}%`]
2200
2687
  );
2201
2688
  if (!feature) {
2202
- console.log(chalk13.red(`
2689
+ console.log(chalk14.red(`
2203
2690
  Feature "${nameOrId}" not found.
2204
2691
  `));
2205
2692
  closeDb();
@@ -2219,7 +2706,7 @@ async function updateCommand(type, nameOrId, options) {
2219
2706
  if (options.progress !== void 0) {
2220
2707
  const p = parseProgress2(options.progress);
2221
2708
  if (!p) {
2222
- console.log(chalk13.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)
2709
+ console.log(chalk14.yellow(` \u26A0 --progress "${options.progress}" ignored (expected N/N format)
2223
2710
  `));
2224
2711
  } else {
2225
2712
  sets.push("progress_done = ?", "progress_total = ?");
@@ -2234,8 +2721,31 @@ async function updateCommand(type, nameOrId, options) {
2234
2721
  sets.push("status = ?");
2235
2722
  params.push(options.status);
2236
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
+ }
2237
2747
  if (sets.length === 0) {
2238
- console.log(chalk13.yellow("\n Nothing to update \u2014 pass at least one option.\n"));
2748
+ console.log(chalk14.yellow("\n Nothing to update \u2014 pass at least one option.\n"));
2239
2749
  closeDb();
2240
2750
  return;
2241
2751
  }
@@ -2247,22 +2757,170 @@ async function updateCommand(type, nameOrId, options) {
2247
2757
  );
2248
2758
  saveDb();
2249
2759
  closeDb();
2250
- console.log(chalk13.green(` \u2713 Updated: ${feature.name}`));
2760
+ console.log(chalk14.green(` \u2713 Updated: ${feature.name}`));
2251
2761
  }
2252
2762
 
2253
- // 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";
2254
2770
  var require2 = createRequire(import.meta.url);
2255
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");
2256
2914
  var program = new Command();
2257
- 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);
2258
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 }));
2259
- 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 }));
2260
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);
2261
2919
  program.command("complete <feature>").description("Mark a feature as done and release the claim").action(completeCommand);
2262
2920
  program.command("sync").description("Regenerate PROJECT_STATE.md and AGENTS.md from SQLite").action(syncCommand);
2263
2921
  program.command("next").description("Show next available (unclaimed) feature").action(nextCommand);
2264
2922
  program.command("log").description("Show session timeline").option("-s, --session <id>", "Show details for a specific session").action(logCommand);
2265
- 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);
2266
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(
2267
2925
  (opts) => ingestCommand({
2268
2926
  source: opts.source,
@@ -2281,13 +2939,24 @@ program.command("watch").description("Watch for session end and auto-ingest tran
2281
2939
  projectPath: opts.projectPath
2282
2940
  })
2283
2941
  );
2284
- 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(
2285
2943
  (type, name, opts) => updateCommand(type, name, {
2286
2944
  description: opts.description,
2287
2945
  items: opts.items,
2288
2946
  progress: opts.progress,
2289
2947
  priority: opts.priority,
2290
- status: opts.status
2948
+ status: opts.status,
2949
+ group: opts.group
2291
2950
  })
2292
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
+ });
2293
2962
  program.parse();