@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.
- package/dist/index.js +981 -312
- 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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
890
|
+
console.log(chalk2.bold(`
|
|
567
891
|
groundctl init \u2014 ${projectName}
|
|
568
892
|
`));
|
|
569
|
-
console.log(
|
|
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(
|
|
898
|
+
console.log(chalk2.yellow(" \u26A0 Not a git repo \u2014 skipping --import-from-git"));
|
|
575
899
|
} else {
|
|
576
|
-
console.log(
|
|
900
|
+
console.log(chalk2.gray(" Importing sessions from git history..."));
|
|
577
901
|
const result = importFromGit(db, cwd);
|
|
578
902
|
console.log(
|
|
579
|
-
|
|
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(
|
|
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
|
-
|
|
916
|
+
writeFileSync3(join3(claudeHooksDir, "pre-session.sh"), PRE_SESSION_HOOK);
|
|
594
917
|
chmodSync(join3(claudeHooksDir, "pre-session.sh"), 493);
|
|
595
|
-
|
|
918
|
+
writeFileSync3(join3(claudeHooksDir, "post-session.sh"), POST_SESSION_HOOK);
|
|
596
919
|
chmodSync(join3(claudeHooksDir, "post-session.sh"), 493);
|
|
597
|
-
console.log(
|
|
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
|
-
|
|
927
|
+
writeFileSync3(join3(codexHooksDir, "pre-session.sh"), codexPre);
|
|
605
928
|
chmodSync(join3(codexHooksDir, "pre-session.sh"), 493);
|
|
606
|
-
|
|
929
|
+
writeFileSync3(join3(codexHooksDir, "post-session.sh"), codexPost);
|
|
607
930
|
chmodSync(join3(codexHooksDir, "post-session.sh"), 493);
|
|
608
|
-
console.log(
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
console.log(
|
|
612
|
-
console.log(
|
|
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(
|
|
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(
|
|
626
|
-
console.log(
|
|
627
|
-
console.log(
|
|
628
|
-
console.log(
|
|
629
|
-
console.log(
|
|
630
|
-
console.log(
|
|
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(
|
|
633
|
-
console.log(
|
|
634
|
-
console.log(
|
|
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
|
|
640
|
-
var
|
|
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
|
|
1000
|
+
if (total <= 0) return chalk3.gray("\u2591".repeat(width));
|
|
645
1001
|
const filled = Math.min(width, Math.round(done / total * width));
|
|
646
|
-
return
|
|
1002
|
+
return chalk3.green("\u2588".repeat(filled)) + chalk3.gray("\u2591".repeat(width - filled));
|
|
647
1003
|
}
|
|
648
|
-
function featureBar(status,
|
|
649
|
-
if (
|
|
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,
|
|
1008
|
+
return progressBar(1, 1, width);
|
|
655
1009
|
case "in_progress":
|
|
656
|
-
return progressBar(1, 2,
|
|
1010
|
+
return progressBar(1, 2, width);
|
|
657
1011
|
case "blocked":
|
|
658
|
-
return
|
|
1012
|
+
return chalk3.red("\u2591".repeat(width));
|
|
659
1013
|
default:
|
|
660
|
-
return
|
|
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(
|
|
670
|
-
const items =
|
|
1017
|
+
function wrapItems(csv, maxWidth) {
|
|
1018
|
+
const items = csv.split(",").map((s) => s.trim()).filter(Boolean);
|
|
671
1019
|
const lines = [];
|
|
672
|
-
let
|
|
1020
|
+
let cur = "";
|
|
673
1021
|
for (const item of items) {
|
|
674
|
-
const next =
|
|
675
|
-
if (next.length > maxWidth &&
|
|
676
|
-
lines.push(
|
|
677
|
-
|
|
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 (
|
|
1028
|
+
if (cur) lines.push(cur);
|
|
683
1029
|
return lines;
|
|
684
1030
|
}
|
|
685
|
-
function timeSince(
|
|
686
|
-
const
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
|
706
|
-
|
|
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 '
|
|
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(
|
|
1229
|
+
console.log("");
|
|
1230
|
+
console.log(chalk3.bold(` ${projectName} \u2014 no features tracked yet
|
|
730
1231
|
`));
|
|
731
|
-
console.log(
|
|
732
|
-
console.log(
|
|
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
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
|
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(
|
|
1284
|
+
console.log(chalk4.red(`
|
|
826
1285
|
Feature "${featureIdOrName}" not found.
|
|
827
1286
|
`));
|
|
828
|
-
console.log(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1321
|
+
console.log(chalk4.gray("\n Available instead:"));
|
|
863
1322
|
for (const alt of alternatives) {
|
|
864
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1384
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
926
1385
|
import { join as join4 } from "path";
|
|
927
|
-
import
|
|
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
|
-
|
|
936
|
-
|
|
1394
|
+
writeFileSync4(join4(cwd, "PROJECT_STATE.md"), projectState);
|
|
1395
|
+
writeFileSync4(join4(cwd, "AGENTS.md"), agentsMd);
|
|
937
1396
|
if (!opts?.silent) {
|
|
938
|
-
console.log(
|
|
939
|
-
console.log(
|
|
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
|
|
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(
|
|
1423
|
+
console.log(chalk6.yellow("\n No available features to claim.\n"));
|
|
965
1424
|
return;
|
|
966
1425
|
}
|
|
967
|
-
console.log(
|
|
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" ?
|
|
971
|
-
const marker = i === 0 ?
|
|
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(
|
|
1433
|
+
console.log(chalk6.gray(` ${feat.description}`));
|
|
975
1434
|
}
|
|
976
1435
|
}
|
|
977
|
-
console.log(
|
|
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
|
|
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(
|
|
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(
|
|
1457
|
+
console.log(chalk7.bold(`
|
|
999
1458
|
Session ${session.id}`));
|
|
1000
|
-
console.log(
|
|
1001
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1488
|
+
console.log(chalk7.bold(`
|
|
1030
1489
|
Files modified (${files.length}):`));
|
|
1031
1490
|
for (const f of files) {
|
|
1032
|
-
const op = f.operation === "created" ?
|
|
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(
|
|
1499
|
+
console.log(chalk7.yellow("\n No sessions recorded yet.\n"));
|
|
1041
1500
|
closeDb();
|
|
1042
1501
|
return;
|
|
1043
1502
|
}
|
|
1044
|
-
console.log(
|
|
1503
|
+
console.log(chalk7.bold("\n Session timeline:\n"));
|
|
1045
1504
|
for (const s of sessions) {
|
|
1046
|
-
const status = s.ended_at ?
|
|
1505
|
+
const status = s.ended_at ? chalk7.green("done") : chalk7.yellow("active");
|
|
1047
1506
|
console.log(
|
|
1048
|
-
` ${
|
|
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(
|
|
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
|
|
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(
|
|
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 ?
|
|
1109
|
-
console.log(
|
|
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(
|
|
1605
|
+
console.log(chalk8.green(`
|
|
1119
1606
|
\u2713 Session created: ${id} (${agent})
|
|
1120
1607
|
`));
|
|
1121
1608
|
} else {
|
|
1122
|
-
console.log(
|
|
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
|
|
1134
|
-
import
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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" ?
|
|
1467
|
-
console.log(
|
|
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
|
|
1964
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
1478
1965
|
import { join as join6 } from "path";
|
|
1479
|
-
import
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1630
|
-
console.log(
|
|
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
|
-
|
|
1657
|
-
console.log(
|
|
2143
|
+
writeFileSync5(outPath, report);
|
|
2144
|
+
console.log(chalk10.green(`
|
|
1658
2145
|
\u2713 SESSION_REPORT.md written (session ${session.id})
|
|
1659
2146
|
`));
|
|
1660
|
-
console.log(
|
|
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
|
|
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(
|
|
2203
|
+
console.log(chalk11.bold(` ${projectName} \u2014 Health Score: ${totalScore}/100
|
|
1717
2204
|
`));
|
|
1718
|
-
const featureColor = featurePct >= 0.7 ?
|
|
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)}%)`) +
|
|
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 ?
|
|
2211
|
+
const testColor = testFiles > 0 ? chalk11.green : chalk11.red;
|
|
1725
2212
|
console.log(
|
|
1726
|
-
` ${testMark} Tests ${testColor(String(testFiles) + " test files")}` + (testFiles === 0 ?
|
|
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 ?
|
|
2216
|
+
const decColor = decisionCount > 0 ? chalk11.green : chalk11.yellow;
|
|
1730
2217
|
console.log(
|
|
1731
|
-
` ${decMark} Arch log ${decColor(decisionCount + " entries")}` +
|
|
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 ?
|
|
2221
|
+
const claimColor = staleClaims === 0 ? chalk11.green : chalk11.red;
|
|
1735
2222
|
console.log(
|
|
1736
|
-
` ${claimMark} Claims ${claimColor(staleClaims > 0 ? staleClaims + " stale (>24h)" : "0 stale")}` +
|
|
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 ?
|
|
2226
|
+
const deployLabel = deployScore > 0 ? chalk11.green("detected") : chalk11.gray("not detected");
|
|
1740
2227
|
console.log(
|
|
1741
|
-
` ${deployMark} Deploy ${deployLabel}` + (deployScore > 0 ?
|
|
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(
|
|
2237
|
+
console.log(chalk11.bold(" Recommendations:"));
|
|
1751
2238
|
for (const r of recommendations) {
|
|
1752
|
-
console.log(
|
|
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
|
|
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(
|
|
1927
|
-
groundctl dashboard \u2192 `) +
|
|
1928
|
-
console.log(
|
|
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
|
|
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
|
|
1948
|
-
import { spawn } from "child_process";
|
|
1949
|
-
import
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2005
|
-
[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] `) +
|
|
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(
|
|
2548
|
+
console.log(chalk13.green(` \u2713 Session ingested \u2014 ${summary}`));
|
|
2062
2549
|
await syncCommand({ silent: true });
|
|
2063
|
-
console.log(
|
|
2550
|
+
console.log(chalk13.gray(" \u21B3 PROJECT_STATE.md + AGENTS.md updated"));
|
|
2064
2551
|
} catch (err) {
|
|
2065
|
-
console.log(
|
|
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(
|
|
2600
|
+
console.log(chalk13.bold("\n groundctl watch") + chalk13.gray(" \u2014 auto-ingest on session end\n"));
|
|
2114
2601
|
console.log(
|
|
2115
|
-
|
|
2602
|
+
chalk13.gray(" Watching: ") + chalk13.blue(transcriptDir.replace(homedir4(), "~"))
|
|
2116
2603
|
);
|
|
2117
|
-
console.log(
|
|
2118
|
-
console.log(
|
|
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 =
|
|
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(
|
|
2618
|
+
console.log(chalk13.green(`
|
|
2132
2619
|
\u2713 groundctl watch running in background (PID ${child.pid})`));
|
|
2133
|
-
console.log(
|
|
2134
|
-
console.log(
|
|
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(
|
|
2628
|
+
console.log(chalk13.yellow(`
|
|
2142
2629
|
\u26A0 A watcher is already running (PID ${existingPid}).`));
|
|
2143
|
-
console.log(
|
|
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(
|
|
2636
|
+
console.log(chalk13.bold("\n groundctl watch\n"));
|
|
2150
2637
|
console.log(
|
|
2151
|
-
|
|
2638
|
+
chalk13.yellow(" No Claude Code transcript directory found for this project yet.")
|
|
2152
2639
|
);
|
|
2153
|
-
console.log(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
2760
|
+
console.log(chalk14.green(` \u2713 Updated: ${feature.name}`));
|
|
2251
2761
|
}
|
|
2252
2762
|
|
|
2253
|
-
// 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";
|
|
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(
|
|
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
|
|
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();
|