@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.
- package/dist/index.js +710 -260
- 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
|
|
486
|
-
const parts = [];
|
|
501
|
+
function collectContextParts(projectPath) {
|
|
487
502
|
const gitLog = run2("git log --oneline --no-merges", projectPath);
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
565
|
+
if (!Array.isArray(obj.features)) throw new Error("Response missing 'features' array");
|
|
566
|
+
return normaliseFeatures(obj.features);
|
|
536
567
|
}
|
|
537
|
-
|
|
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
|
|
556
|
-
|
|
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:
|
|
564
|
-
path:
|
|
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(
|
|
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", (
|
|
576
|
-
data +=
|
|
586
|
+
res.on("data", (c) => {
|
|
587
|
+
data += c.toString();
|
|
577
588
|
});
|
|
578
589
|
res.on("end", () => {
|
|
579
|
-
|
|
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(
|
|
602
|
+
req.write(bodyStr);
|
|
585
603
|
req.end();
|
|
586
604
|
});
|
|
587
605
|
}
|
|
588
|
-
function
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (!
|
|
593
|
-
return
|
|
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
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
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
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
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, (
|
|
671
|
+
rl.question(prompt, (a) => {
|
|
641
672
|
rl.close();
|
|
642
|
-
resolve3(
|
|
673
|
+
resolve3(a.trim().toLowerCase());
|
|
643
674
|
});
|
|
644
675
|
});
|
|
645
676
|
}
|
|
646
677
|
async function editInEditor(features) {
|
|
647
|
-
const
|
|
648
|
-
writeFileSync2(
|
|
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} "${
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
700
|
+
[f.name, f.name, status, f.priority, f.description]
|
|
678
701
|
);
|
|
679
702
|
} else {
|
|
680
703
|
db.run(
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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
|
|
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,
|
|
868
|
-
if (
|
|
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,
|
|
1008
|
+
return progressBar(1, 1, width);
|
|
874
1009
|
case "in_progress":
|
|
875
|
-
return progressBar(1, 2,
|
|
1010
|
+
return progressBar(1, 2, width);
|
|
876
1011
|
case "blocked":
|
|
877
|
-
return chalk3.red("\u2591".repeat(
|
|
1012
|
+
return chalk3.red("\u2591".repeat(width));
|
|
878
1013
|
default:
|
|
879
|
-
return chalk3.gray("\u2591".repeat(
|
|
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(
|
|
889
|
-
const items =
|
|
1017
|
+
function wrapItems(csv, maxWidth) {
|
|
1018
|
+
const items = csv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
890
1019
|
const lines = [];
|
|
891
|
-
let
|
|
1020
|
+
let cur = "";
|
|
892
1021
|
for (const item of items) {
|
|
893
|
-
const next =
|
|
894
|
-
if (next.length > maxWidth &&
|
|
895
|
-
lines.push(
|
|
896
|
-
|
|
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 (
|
|
1028
|
+
if (cur) lines.push(cur);
|
|
902
1029
|
return lines;
|
|
903
1030
|
}
|
|
904
|
-
function timeSince(
|
|
905
|
-
const
|
|
906
|
-
const
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
914
|
-
const
|
|
915
|
-
const
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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 ${
|
|
1157
|
+
console.log(` Features ${progressBar(done, total, AGG_BAR_W)}${aggSuffix}`);
|
|
968
1158
|
console.log("");
|
|
969
|
-
const
|
|
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
|
|
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
|
|
981
|
-
const
|
|
982
|
-
const
|
|
983
|
-
const
|
|
984
|
-
|
|
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
|
|
991
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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/
|
|
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(
|
|
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
|
|
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();
|