@groundctl/cli 0.1.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.d.ts +1 -0
- package/dist/index.js +1638 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, chmodSync, readFileSync as readFileSync3, appendFileSync } from "fs";
|
|
8
|
+
import { join as join3 } from "path";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
|
|
11
|
+
// src/storage/db.ts
|
|
12
|
+
import initSqlJs from "sql.js";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
|
|
17
|
+
// src/storage/schema.ts
|
|
18
|
+
var SCHEMA_VERSION = 1;
|
|
19
|
+
function applySchema(db) {
|
|
20
|
+
db.run(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
22
|
+
key TEXT PRIMARY KEY,
|
|
23
|
+
value TEXT NOT NULL
|
|
24
|
+
)
|
|
25
|
+
`);
|
|
26
|
+
db.run(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS features (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
31
|
+
CHECK (status IN ('pending', 'in_progress', 'done', 'blocked')),
|
|
32
|
+
priority TEXT NOT NULL DEFAULT 'medium'
|
|
33
|
+
CHECK (priority IN ('critical', 'high', 'medium', 'low')),
|
|
34
|
+
description TEXT,
|
|
35
|
+
parallel_safe INTEGER NOT NULL DEFAULT 1,
|
|
36
|
+
session_claimed TEXT,
|
|
37
|
+
claimed_at TEXT,
|
|
38
|
+
completed_at TEXT,
|
|
39
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
40
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
db.run(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
agent TEXT NOT NULL DEFAULT 'claude-code',
|
|
47
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
48
|
+
ended_at TEXT,
|
|
49
|
+
summary TEXT,
|
|
50
|
+
prompt TEXT
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
db.run(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
57
|
+
description TEXT NOT NULL,
|
|
58
|
+
rationale TEXT,
|
|
59
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
db.run(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS files_modified (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
66
|
+
path TEXT NOT NULL,
|
|
67
|
+
operation TEXT NOT NULL DEFAULT 'modified'
|
|
68
|
+
CHECK (operation IN ('created', 'modified', 'deleted')),
|
|
69
|
+
lines_changed INTEGER DEFAULT 0,
|
|
70
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
71
|
+
)
|
|
72
|
+
`);
|
|
73
|
+
db.run(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
75
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
76
|
+
feature_id TEXT NOT NULL REFERENCES features(id),
|
|
77
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
78
|
+
claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
79
|
+
released_at TEXT,
|
|
80
|
+
UNIQUE(feature_id, session_id, claimed_at)
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
db.run(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS feature_dependencies (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
feature_id TEXT NOT NULL REFERENCES features(id),
|
|
87
|
+
depends_on_id TEXT NOT NULL REFERENCES features(id),
|
|
88
|
+
type TEXT NOT NULL DEFAULT 'blocks'
|
|
89
|
+
CHECK (type IN ('blocks', 'suggests')),
|
|
90
|
+
UNIQUE(feature_id, depends_on_id)
|
|
91
|
+
)
|
|
92
|
+
`);
|
|
93
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_deps_feature ON feature_dependencies(feature_id)");
|
|
94
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_deps_depends ON feature_dependencies(depends_on_id)");
|
|
95
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_features_status ON features(status)");
|
|
96
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_claims_feature ON claims(feature_id)");
|
|
97
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_claims_active ON claims(feature_id) WHERE released_at IS NULL");
|
|
98
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_files_session ON files_modified(session_id)");
|
|
99
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_decisions_session ON decisions(session_id)");
|
|
100
|
+
db.run(
|
|
101
|
+
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
102
|
+
[String(SCHEMA_VERSION)]
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/storage/db.ts
|
|
107
|
+
var GROUNDCTL_DIR = join(homedir(), ".groundctl");
|
|
108
|
+
var DB_PATH = join(GROUNDCTL_DIR, "db.sqlite");
|
|
109
|
+
var _db = null;
|
|
110
|
+
var _dbPath = DB_PATH;
|
|
111
|
+
async function openDb(path) {
|
|
112
|
+
if (_db) return _db;
|
|
113
|
+
const dbPath = path ?? DB_PATH;
|
|
114
|
+
_dbPath = dbPath;
|
|
115
|
+
const dir = dirname(dbPath);
|
|
116
|
+
if (!existsSync(dir)) {
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
const SQL = await initSqlJs();
|
|
120
|
+
if (existsSync(dbPath)) {
|
|
121
|
+
const buffer = readFileSync(dbPath);
|
|
122
|
+
_db = new SQL.Database(buffer);
|
|
123
|
+
} else {
|
|
124
|
+
_db = new SQL.Database();
|
|
125
|
+
}
|
|
126
|
+
_db.run("PRAGMA journal_mode = WAL");
|
|
127
|
+
_db.run("PRAGMA busy_timeout = 5000");
|
|
128
|
+
applySchema(_db);
|
|
129
|
+
saveDb();
|
|
130
|
+
return _db;
|
|
131
|
+
}
|
|
132
|
+
function saveDb() {
|
|
133
|
+
if (!_db) return;
|
|
134
|
+
const data = _db.export();
|
|
135
|
+
const buffer = Buffer.from(data);
|
|
136
|
+
const dir = dirname(_dbPath);
|
|
137
|
+
if (!existsSync(dir)) {
|
|
138
|
+
mkdirSync(dir, { recursive: true });
|
|
139
|
+
}
|
|
140
|
+
writeFileSync(_dbPath, buffer);
|
|
141
|
+
}
|
|
142
|
+
function closeDb() {
|
|
143
|
+
if (_db) {
|
|
144
|
+
saveDb();
|
|
145
|
+
_db.close();
|
|
146
|
+
_db = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/storage/query.ts
|
|
151
|
+
function query(db, sql, params = []) {
|
|
152
|
+
const stmt = db.prepare(sql);
|
|
153
|
+
if (params.length > 0) {
|
|
154
|
+
stmt.bind(params);
|
|
155
|
+
}
|
|
156
|
+
const results = [];
|
|
157
|
+
while (stmt.step()) {
|
|
158
|
+
const row = stmt.getAsObject();
|
|
159
|
+
results.push(row);
|
|
160
|
+
}
|
|
161
|
+
stmt.free();
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
function queryOne(db, sql, params = []) {
|
|
165
|
+
const rows = query(db, sql, params);
|
|
166
|
+
return rows[0];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/generators/markdown.ts
|
|
170
|
+
function generateProjectState(db, projectName) {
|
|
171
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
172
|
+
const features = query(db, "SELECT * FROM features ORDER BY created_at");
|
|
173
|
+
const activeClaims = query(
|
|
174
|
+
db,
|
|
175
|
+
`SELECT c.feature_id, f.name as feature_name, c.session_id, c.claimed_at
|
|
176
|
+
FROM claims c JOIN features f ON c.feature_id = f.id
|
|
177
|
+
WHERE c.released_at IS NULL`
|
|
178
|
+
);
|
|
179
|
+
const available = query(
|
|
180
|
+
db,
|
|
181
|
+
`SELECT f.* FROM features f
|
|
182
|
+
WHERE f.status = 'pending'
|
|
183
|
+
AND f.id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
184
|
+
ORDER BY CASE f.priority
|
|
185
|
+
WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
186
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3
|
|
187
|
+
END`
|
|
188
|
+
);
|
|
189
|
+
const decisions = query(
|
|
190
|
+
db,
|
|
191
|
+
"SELECT d.session_id, d.description, d.rationale FROM decisions d ORDER BY d.created_at"
|
|
192
|
+
);
|
|
193
|
+
const sessions = query(
|
|
194
|
+
db,
|
|
195
|
+
"SELECT * FROM sessions ORDER BY started_at DESC"
|
|
196
|
+
);
|
|
197
|
+
const lastSession = sessions[0];
|
|
198
|
+
const done = features.filter((f) => f.status === "done");
|
|
199
|
+
const blocked = features.filter((f) => f.status === "blocked");
|
|
200
|
+
let md = `# ${projectName} \u2014 Product State
|
|
201
|
+
`;
|
|
202
|
+
md += `Last updated: ${now}`;
|
|
203
|
+
if (lastSession) {
|
|
204
|
+
md += ` | Last session: ${lastSession.id}`;
|
|
205
|
+
}
|
|
206
|
+
if (features.length > 0) {
|
|
207
|
+
const pct = Math.round(done.length / features.length * 100);
|
|
208
|
+
md += ` | Progress: ${pct}%`;
|
|
209
|
+
}
|
|
210
|
+
md += "\n\n";
|
|
211
|
+
if (done.length > 0) {
|
|
212
|
+
md += "## What's been built\n";
|
|
213
|
+
for (const f of done) {
|
|
214
|
+
md += `- ${f.name}`;
|
|
215
|
+
if (f.completed_at) md += ` (completed ${String(f.completed_at).slice(0, 10)})`;
|
|
216
|
+
md += "\n";
|
|
217
|
+
}
|
|
218
|
+
md += "\n";
|
|
219
|
+
}
|
|
220
|
+
if (activeClaims.length > 0) {
|
|
221
|
+
md += "## Currently claimed\n";
|
|
222
|
+
for (const claim of activeClaims) {
|
|
223
|
+
md += `- ${claim.feature_name} \u2192 session ${claim.session_id} (started ${claim.claimed_at})
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
md += "\n";
|
|
227
|
+
}
|
|
228
|
+
if (available.length > 0) {
|
|
229
|
+
md += "## Available next\n";
|
|
230
|
+
for (const f of available) {
|
|
231
|
+
md += `- ${f.name} (priority: ${f.priority})`;
|
|
232
|
+
if (f.description) md += ` \u2014 ${f.description}`;
|
|
233
|
+
md += "\n";
|
|
234
|
+
}
|
|
235
|
+
md += "\n";
|
|
236
|
+
}
|
|
237
|
+
if (blocked.length > 0) {
|
|
238
|
+
md += "## Blocked\n";
|
|
239
|
+
for (const f of blocked) {
|
|
240
|
+
md += `- ${f.name}`;
|
|
241
|
+
if (f.description) md += ` \u2014 ${f.description}`;
|
|
242
|
+
md += "\n";
|
|
243
|
+
}
|
|
244
|
+
md += "\n";
|
|
245
|
+
}
|
|
246
|
+
if (decisions.length > 0) {
|
|
247
|
+
md += "## Decisions made\n";
|
|
248
|
+
for (const d of decisions) {
|
|
249
|
+
md += `- ${d.session_id}: ${d.description}`;
|
|
250
|
+
if (d.rationale) md += ` \u2014 ${d.rationale}`;
|
|
251
|
+
md += "\n";
|
|
252
|
+
}
|
|
253
|
+
md += "\n";
|
|
254
|
+
}
|
|
255
|
+
if (sessions.length > 0) {
|
|
256
|
+
md += "## Recent sessions\n";
|
|
257
|
+
for (const s of sessions.slice(0, 10)) {
|
|
258
|
+
const status = s.ended_at ? "done" : "active";
|
|
259
|
+
md += `- ${s.id} (${s.agent}, ${status})`;
|
|
260
|
+
if (s.summary) md += ` \u2014 ${s.summary}`;
|
|
261
|
+
md += "\n";
|
|
262
|
+
}
|
|
263
|
+
md += "\n";
|
|
264
|
+
}
|
|
265
|
+
return md;
|
|
266
|
+
}
|
|
267
|
+
function generateAgentsMd(db, projectName) {
|
|
268
|
+
const available = query(
|
|
269
|
+
db,
|
|
270
|
+
`SELECT f.name, f.priority, f.description FROM features f
|
|
271
|
+
WHERE f.status = 'pending'
|
|
272
|
+
AND f.id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
273
|
+
ORDER BY CASE f.priority
|
|
274
|
+
WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
275
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3
|
|
276
|
+
END`
|
|
277
|
+
);
|
|
278
|
+
const activeClaims = query(
|
|
279
|
+
db,
|
|
280
|
+
`SELECT f.name as feature_name, c.session_id
|
|
281
|
+
FROM claims c JOIN features f ON c.feature_id = f.id
|
|
282
|
+
WHERE c.released_at IS NULL`
|
|
283
|
+
);
|
|
284
|
+
let md = `# ${projectName} \u2014 Agent Instructions
|
|
285
|
+
|
|
286
|
+
`;
|
|
287
|
+
md += "This file is auto-generated by groundctl. Read it before starting work.\n\n";
|
|
288
|
+
md += "## Before you start\n";
|
|
289
|
+
md += "1. Read PROJECT_STATE.md for full product context\n";
|
|
290
|
+
md += "2. Run `groundctl next` to see available features\n";
|
|
291
|
+
md += "3. Run `groundctl claim <feature>` to reserve your work\n";
|
|
292
|
+
md += "4. When done, run `groundctl complete <feature>`\n";
|
|
293
|
+
md += "5. Run `groundctl sync` to update state files\n\n";
|
|
294
|
+
if (activeClaims.length > 0) {
|
|
295
|
+
md += "## Do NOT work on (already claimed)\n";
|
|
296
|
+
for (const c of activeClaims) {
|
|
297
|
+
md += `- ${c.feature_name} (session ${c.session_id})
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
300
|
+
md += "\n";
|
|
301
|
+
}
|
|
302
|
+
if (available.length > 0) {
|
|
303
|
+
md += "## Available to claim\n";
|
|
304
|
+
for (const f of available) {
|
|
305
|
+
md += `- ${f.name} (${f.priority})`;
|
|
306
|
+
if (f.description) md += ` \u2014 ${f.description}`;
|
|
307
|
+
md += "\n";
|
|
308
|
+
}
|
|
309
|
+
md += "\n";
|
|
310
|
+
}
|
|
311
|
+
return md;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/ingest/git-import.ts
|
|
315
|
+
import { execSync } from "child_process";
|
|
316
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
317
|
+
import { join as join2 } from "path";
|
|
318
|
+
function run(cmd, cwd) {
|
|
319
|
+
try {
|
|
320
|
+
return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
321
|
+
} catch {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function parseGitLog(cwd) {
|
|
326
|
+
const logOutput = run(
|
|
327
|
+
'git log --format="%H|||%ai|||%s" --no-merges',
|
|
328
|
+
cwd
|
|
329
|
+
);
|
|
330
|
+
if (!logOutput.trim()) return [];
|
|
331
|
+
const commits = [];
|
|
332
|
+
for (const line of logOutput.trim().split("\n")) {
|
|
333
|
+
const parts = line.split("|||");
|
|
334
|
+
if (parts.length < 3) continue;
|
|
335
|
+
const [hash, date, ...messageParts] = parts;
|
|
336
|
+
const message = messageParts.join("|||").trim();
|
|
337
|
+
const diffOutput = run(
|
|
338
|
+
`git diff-tree --no-commit-id -r --name-status "${hash.trim()}"`,
|
|
339
|
+
cwd
|
|
340
|
+
);
|
|
341
|
+
const filesChanged = [];
|
|
342
|
+
for (const diffLine of diffOutput.trim().split("\n")) {
|
|
343
|
+
if (!diffLine.trim()) continue;
|
|
344
|
+
const [status, ...pathParts] = diffLine.trim().split(/\s+/);
|
|
345
|
+
const filePath = pathParts.join(" ");
|
|
346
|
+
if (!filePath) continue;
|
|
347
|
+
let operation = "modified";
|
|
348
|
+
if (status.startsWith("A")) operation = "created";
|
|
349
|
+
else if (status.startsWith("D")) operation = "deleted";
|
|
350
|
+
else if (status.startsWith("M") || status.startsWith("R")) operation = "modified";
|
|
351
|
+
const numstatLine = run(
|
|
352
|
+
`git diff-tree --no-commit-id -r --numstat "${hash.trim()}" -- "${filePath}"`,
|
|
353
|
+
cwd
|
|
354
|
+
);
|
|
355
|
+
let insertions = 0;
|
|
356
|
+
let deletions = 0;
|
|
357
|
+
if (numstatLine.trim()) {
|
|
358
|
+
const [ins, del] = numstatLine.trim().split(/\s+/);
|
|
359
|
+
insertions = parseInt(ins) || 0;
|
|
360
|
+
deletions = parseInt(del) || 0;
|
|
361
|
+
}
|
|
362
|
+
filesChanged.push({ path: filePath, operation, insertions, deletions });
|
|
363
|
+
}
|
|
364
|
+
commits.push({
|
|
365
|
+
hash: hash.trim(),
|
|
366
|
+
message,
|
|
367
|
+
date: date.trim(),
|
|
368
|
+
filesChanged
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return commits.reverse();
|
|
372
|
+
}
|
|
373
|
+
function parseProjectStateMd(content) {
|
|
374
|
+
const features = [];
|
|
375
|
+
const lines = content.split("\n");
|
|
376
|
+
let section = "";
|
|
377
|
+
for (const line of lines) {
|
|
378
|
+
const trimmed = line.trim();
|
|
379
|
+
if (trimmed.startsWith("## ")) {
|
|
380
|
+
section = trimmed.toLowerCase();
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (!trimmed.startsWith("- ") && !trimmed.startsWith("* ")) continue;
|
|
384
|
+
const item = trimmed.slice(2).trim();
|
|
385
|
+
if (!item || item.length < 3) continue;
|
|
386
|
+
const name = item.split("(")[0].split("\u2192")[0].split("\u2014")[0].trim();
|
|
387
|
+
if (!name || name.length < 3) continue;
|
|
388
|
+
let status = "pending";
|
|
389
|
+
let priority = "medium";
|
|
390
|
+
if (section.includes("built") || section.includes("done") || section.includes("complete")) {
|
|
391
|
+
status = "done";
|
|
392
|
+
} else if (section.includes("claimed") || section.includes("in progress") || section.includes("current")) {
|
|
393
|
+
status = "in_progress";
|
|
394
|
+
} else if (section.includes("available") || section.includes("next")) {
|
|
395
|
+
status = "pending";
|
|
396
|
+
} else if (section.includes("blocked")) {
|
|
397
|
+
status = "blocked";
|
|
398
|
+
}
|
|
399
|
+
if (/priority:\s*critical|critical\)/i.test(item)) priority = "critical";
|
|
400
|
+
else if (/priority:\s*high|high\)/i.test(item)) priority = "high";
|
|
401
|
+
else if (/priority:\s*low|low\)/i.test(item)) priority = "low";
|
|
402
|
+
features.push({ name, status, priority });
|
|
403
|
+
}
|
|
404
|
+
return features;
|
|
405
|
+
}
|
|
406
|
+
function featureIdFromName(name) {
|
|
407
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
|
|
408
|
+
}
|
|
409
|
+
function importFromGit(db, projectPath) {
|
|
410
|
+
let sessionsCreated = 0;
|
|
411
|
+
let featuresImported = 0;
|
|
412
|
+
const psMdPath = join2(projectPath, "PROJECT_STATE.md");
|
|
413
|
+
if (existsSync2(psMdPath)) {
|
|
414
|
+
const content = readFileSync2(psMdPath, "utf-8");
|
|
415
|
+
const features = parseProjectStateMd(content);
|
|
416
|
+
for (const feat of features) {
|
|
417
|
+
const id = featureIdFromName(feat.name);
|
|
418
|
+
if (!id) continue;
|
|
419
|
+
const exists = queryOne(db, "SELECT id FROM features WHERE id = ?", [id]);
|
|
420
|
+
if (!exists) {
|
|
421
|
+
db.run(
|
|
422
|
+
"INSERT INTO features (id, name, status, priority) VALUES (?, ?, ?, ?)",
|
|
423
|
+
[id, feat.name, feat.status, feat.priority]
|
|
424
|
+
);
|
|
425
|
+
featuresImported++;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const commits = parseGitLog(projectPath);
|
|
430
|
+
if (commits.length === 0) return { sessionsCreated, featuresImported };
|
|
431
|
+
const SESSION_GAP_MS = 4 * 60 * 60 * 1e3;
|
|
432
|
+
const sessions = [];
|
|
433
|
+
let currentSession = [];
|
|
434
|
+
let lastDate = null;
|
|
435
|
+
for (const commit of commits) {
|
|
436
|
+
const commitDate = new Date(commit.date);
|
|
437
|
+
if (lastDate && commitDate.getTime() - lastDate.getTime() > SESSION_GAP_MS) {
|
|
438
|
+
if (currentSession.length > 0) sessions.push(currentSession);
|
|
439
|
+
currentSession = [];
|
|
440
|
+
}
|
|
441
|
+
currentSession.push(commit);
|
|
442
|
+
lastDate = commitDate;
|
|
443
|
+
}
|
|
444
|
+
if (currentSession.length > 0) sessions.push(currentSession);
|
|
445
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
446
|
+
const sessionCommits = sessions[i];
|
|
447
|
+
const sessionId = `S${i + 1}`;
|
|
448
|
+
const firstCommit = sessionCommits[0];
|
|
449
|
+
const lastCommit = sessionCommits[sessionCommits.length - 1];
|
|
450
|
+
const exists = queryOne(db, "SELECT id FROM sessions WHERE id = ?", [sessionId]);
|
|
451
|
+
if (exists) continue;
|
|
452
|
+
const summary = sessionCommits.map((c) => c.message).slice(0, 3).join("; ").slice(0, 200);
|
|
453
|
+
db.run(
|
|
454
|
+
"INSERT INTO sessions (id, agent, started_at, ended_at, summary) VALUES (?, 'claude-code', ?, ?, ?)",
|
|
455
|
+
[sessionId, firstCommit.date, lastCommit.date, summary]
|
|
456
|
+
);
|
|
457
|
+
sessionsCreated++;
|
|
458
|
+
const filesInSession = /* @__PURE__ */ new Map();
|
|
459
|
+
for (const commit of sessionCommits) {
|
|
460
|
+
for (const file of commit.filesChanged) {
|
|
461
|
+
const existing = filesInSession.get(file.path);
|
|
462
|
+
if (!existing) {
|
|
463
|
+
filesInSession.set(file.path, { ...file });
|
|
464
|
+
} else {
|
|
465
|
+
existing.insertions += file.insertions;
|
|
466
|
+
existing.deletions += file.deletions;
|
|
467
|
+
if (file.operation === "deleted") existing.operation = "deleted";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const [, file] of filesInSession) {
|
|
472
|
+
db.run(
|
|
473
|
+
"INSERT INTO files_modified (session_id, path, operation, lines_changed) VALUES (?, ?, ?, ?)",
|
|
474
|
+
[
|
|
475
|
+
sessionId,
|
|
476
|
+
file.path,
|
|
477
|
+
file.operation,
|
|
478
|
+
file.insertions + file.deletions
|
|
479
|
+
]
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
saveDb();
|
|
484
|
+
return { sessionsCreated, featuresImported };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/commands/init.ts
|
|
488
|
+
var PRE_SESSION_HOOK = `#!/bin/bash
|
|
489
|
+
# groundctl \u2014 pre-session hook for Claude Code
|
|
490
|
+
# Reads product state before the agent starts working
|
|
491
|
+
|
|
492
|
+
groundctl sync 2>/dev/null
|
|
493
|
+
if [ -f PROJECT_STATE.md ]; then
|
|
494
|
+
echo "--- groundctl: Product state loaded ---"
|
|
495
|
+
cat PROJECT_STATE.md
|
|
496
|
+
fi
|
|
497
|
+
if [ -f AGENTS.md ]; then
|
|
498
|
+
cat AGENTS.md
|
|
499
|
+
fi
|
|
500
|
+
`;
|
|
501
|
+
var POST_SESSION_HOOK = `#!/bin/bash
|
|
502
|
+
# groundctl \u2014 post-session hook for Claude Code
|
|
503
|
+
# Updates product state after the agent finishes
|
|
504
|
+
|
|
505
|
+
set -euo pipefail
|
|
506
|
+
|
|
507
|
+
if ! command -v groundctl &>/dev/null; then
|
|
508
|
+
exit 0
|
|
509
|
+
fi
|
|
510
|
+
|
|
511
|
+
groundctl ingest \\
|
|
512
|
+
--source claude-code \\
|
|
513
|
+
\${CLAUDE_SESSION_ID:+--session-id "$CLAUDE_SESSION_ID"} \\
|
|
514
|
+
\${CLAUDE_TRANSCRIPT_PATH:+--transcript "$CLAUDE_TRANSCRIPT_PATH"} \\
|
|
515
|
+
--project-path "$PWD" \\
|
|
516
|
+
--no-sync 2>/dev/null || true
|
|
517
|
+
|
|
518
|
+
groundctl sync 2>/dev/null || true
|
|
519
|
+
echo "--- groundctl: Product state updated ---"
|
|
520
|
+
`;
|
|
521
|
+
async function initCommand(options) {
|
|
522
|
+
const cwd = process.cwd();
|
|
523
|
+
const projectName = cwd.split("/").pop() ?? "unknown";
|
|
524
|
+
console.log(chalk.bold(`
|
|
525
|
+
groundctl init \u2014 ${projectName}
|
|
526
|
+
`));
|
|
527
|
+
console.log(chalk.gray(" Creating SQLite database..."));
|
|
528
|
+
const db = await openDb();
|
|
529
|
+
if (options.importFromGit) {
|
|
530
|
+
const isGitRepo = existsSync3(join3(cwd, ".git"));
|
|
531
|
+
if (!isGitRepo) {
|
|
532
|
+
console.log(chalk.yellow(" \u26A0 Not a git repo \u2014 skipping --import-from-git"));
|
|
533
|
+
} else {
|
|
534
|
+
console.log(chalk.gray(" Importing from git history..."));
|
|
535
|
+
const result = importFromGit(db, cwd);
|
|
536
|
+
console.log(
|
|
537
|
+
chalk.green(
|
|
538
|
+
` \u2713 Git import: ${result.sessionsCreated} sessions, ${result.featuresImported} features`
|
|
539
|
+
)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const projectState = generateProjectState(db, projectName);
|
|
544
|
+
const agentsMd = generateAgentsMd(db, projectName);
|
|
545
|
+
closeDb();
|
|
546
|
+
console.log(chalk.green(" \u2713 Database ready"));
|
|
547
|
+
const claudeHooksDir = join3(cwd, ".claude", "hooks");
|
|
548
|
+
if (!existsSync3(claudeHooksDir)) {
|
|
549
|
+
mkdirSync2(claudeHooksDir, { recursive: true });
|
|
550
|
+
}
|
|
551
|
+
writeFileSync2(join3(claudeHooksDir, "pre-session.sh"), PRE_SESSION_HOOK);
|
|
552
|
+
chmodSync(join3(claudeHooksDir, "pre-session.sh"), 493);
|
|
553
|
+
writeFileSync2(join3(claudeHooksDir, "post-session.sh"), POST_SESSION_HOOK);
|
|
554
|
+
chmodSync(join3(claudeHooksDir, "post-session.sh"), 493);
|
|
555
|
+
console.log(chalk.green(" \u2713 Claude Code hooks installed"));
|
|
556
|
+
const codexHooksDir = join3(cwd, ".codex", "hooks");
|
|
557
|
+
if (!existsSync3(codexHooksDir)) {
|
|
558
|
+
mkdirSync2(codexHooksDir, { recursive: true });
|
|
559
|
+
}
|
|
560
|
+
const codexPre = PRE_SESSION_HOOK.replace("Claude Code", "Codex");
|
|
561
|
+
const codexPost = POST_SESSION_HOOK.replace("Claude Code", "Codex").replace("claude-code", "codex");
|
|
562
|
+
writeFileSync2(join3(codexHooksDir, "pre-session.sh"), codexPre);
|
|
563
|
+
chmodSync(join3(codexHooksDir, "pre-session.sh"), 493);
|
|
564
|
+
writeFileSync2(join3(codexHooksDir, "post-session.sh"), codexPost);
|
|
565
|
+
chmodSync(join3(codexHooksDir, "post-session.sh"), 493);
|
|
566
|
+
console.log(chalk.green(" \u2713 Codex hooks installed"));
|
|
567
|
+
writeFileSync2(join3(cwd, "PROJECT_STATE.md"), projectState);
|
|
568
|
+
writeFileSync2(join3(cwd, "AGENTS.md"), agentsMd);
|
|
569
|
+
console.log(chalk.green(" \u2713 PROJECT_STATE.md generated"));
|
|
570
|
+
console.log(chalk.green(" \u2713 AGENTS.md generated"));
|
|
571
|
+
const gitignorePath = join3(cwd, ".gitignore");
|
|
572
|
+
const gitignoreEntry = "\n# groundctl local state\n.groundctl/\n";
|
|
573
|
+
if (existsSync3(gitignorePath)) {
|
|
574
|
+
const content = readFileSync3(gitignorePath, "utf-8");
|
|
575
|
+
if (!content.includes(".groundctl/")) {
|
|
576
|
+
appendFileSync(gitignorePath, gitignoreEntry);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
console.log(chalk.bold.green(`
|
|
580
|
+
\u2713 groundctl initialized for ${projectName}
|
|
581
|
+
`));
|
|
582
|
+
if (!options.importFromGit) {
|
|
583
|
+
console.log(chalk.gray(" Next steps:"));
|
|
584
|
+
console.log(chalk.gray(" groundctl add feature -n 'my-feature' -p high"));
|
|
585
|
+
console.log(chalk.gray(" groundctl status"));
|
|
586
|
+
console.log(chalk.gray(" groundctl claim my-feature"));
|
|
587
|
+
console.log(chalk.gray("\n Or bootstrap from git history:"));
|
|
588
|
+
console.log(chalk.gray(" groundctl init --import-from-git\n"));
|
|
589
|
+
} else {
|
|
590
|
+
console.log(chalk.gray(" Next steps:"));
|
|
591
|
+
console.log(chalk.gray(" groundctl status"));
|
|
592
|
+
console.log(chalk.gray(" groundctl next\n"));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/commands/status.ts
|
|
597
|
+
import chalk2 from "chalk";
|
|
598
|
+
function progressBar(done, total, width = 20) {
|
|
599
|
+
if (total === 0) return chalk2.gray("\u2591".repeat(width));
|
|
600
|
+
const filled = Math.round(done / total * width);
|
|
601
|
+
const empty = width - filled;
|
|
602
|
+
return chalk2.green("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty));
|
|
603
|
+
}
|
|
604
|
+
async function statusCommand() {
|
|
605
|
+
const db = await openDb();
|
|
606
|
+
const projectName = process.cwd().split("/").pop() ?? "unknown";
|
|
607
|
+
const statusCounts = query(
|
|
608
|
+
db,
|
|
609
|
+
"SELECT status, COUNT(*) as count FROM features GROUP BY status"
|
|
610
|
+
);
|
|
611
|
+
const counts = {
|
|
612
|
+
pending: 0,
|
|
613
|
+
in_progress: 0,
|
|
614
|
+
done: 0,
|
|
615
|
+
blocked: 0
|
|
616
|
+
};
|
|
617
|
+
for (const row of statusCounts) {
|
|
618
|
+
counts[row.status] = row.count;
|
|
619
|
+
}
|
|
620
|
+
const total = counts.pending + counts.in_progress + counts.done + counts.blocked;
|
|
621
|
+
const activeClaims = query(
|
|
622
|
+
db,
|
|
623
|
+
`SELECT c.feature_id, f.name as feature_name, c.session_id, c.claimed_at
|
|
624
|
+
FROM claims c
|
|
625
|
+
JOIN features f ON c.feature_id = f.id
|
|
626
|
+
WHERE c.released_at IS NULL`
|
|
627
|
+
);
|
|
628
|
+
const available = query(
|
|
629
|
+
db,
|
|
630
|
+
`SELECT f.id, f.name, f.priority
|
|
631
|
+
FROM features f
|
|
632
|
+
WHERE f.status = 'pending'
|
|
633
|
+
AND f.id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
634
|
+
ORDER BY
|
|
635
|
+
CASE f.priority
|
|
636
|
+
WHEN 'critical' THEN 0
|
|
637
|
+
WHEN 'high' THEN 1
|
|
638
|
+
WHEN 'medium' THEN 2
|
|
639
|
+
WHEN 'low' THEN 3
|
|
640
|
+
END`
|
|
641
|
+
);
|
|
642
|
+
const sessionCount = queryOne(
|
|
643
|
+
db,
|
|
644
|
+
"SELECT COUNT(*) as count FROM sessions"
|
|
645
|
+
)?.count ?? 0;
|
|
646
|
+
closeDb();
|
|
647
|
+
console.log("");
|
|
648
|
+
if (total === 0) {
|
|
649
|
+
console.log(chalk2.bold(` ${projectName} \u2014 no features tracked yet
|
|
650
|
+
`));
|
|
651
|
+
console.log(chalk2.gray(" Add features with: groundctl add feature -n 'my-feature'"));
|
|
652
|
+
console.log(chalk2.gray(" Then run: groundctl status\n"));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const pct = total > 0 ? Math.round(counts.done / total * 100) : 0;
|
|
656
|
+
console.log(
|
|
657
|
+
chalk2.bold(` ${projectName} \u2014 ${pct}% implemented`) + chalk2.gray(` (${sessionCount} sessions)`)
|
|
658
|
+
);
|
|
659
|
+
console.log("");
|
|
660
|
+
console.log(
|
|
661
|
+
` Features ${progressBar(counts.done, total)} ${counts.done}/${total} done`
|
|
662
|
+
);
|
|
663
|
+
if (counts.in_progress > 0) {
|
|
664
|
+
console.log(chalk2.yellow(` ${counts.in_progress} in progress`));
|
|
665
|
+
}
|
|
666
|
+
if (counts.blocked > 0) {
|
|
667
|
+
console.log(chalk2.red(` ${counts.blocked} blocked`));
|
|
668
|
+
}
|
|
669
|
+
console.log("");
|
|
670
|
+
if (activeClaims.length > 0) {
|
|
671
|
+
console.log(chalk2.bold(" Claimed:"));
|
|
672
|
+
for (const claim of activeClaims) {
|
|
673
|
+
const elapsed = timeSince(claim.claimed_at);
|
|
674
|
+
console.log(
|
|
675
|
+
chalk2.yellow(` \u25CF ${claim.feature_name} \u2192 session ${claim.session_id} (${elapsed})`)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
console.log("");
|
|
679
|
+
}
|
|
680
|
+
if (available.length > 0) {
|
|
681
|
+
console.log(chalk2.bold(" Available:"));
|
|
682
|
+
for (const feat of available.slice(0, 5)) {
|
|
683
|
+
const pColor = feat.priority === "critical" || feat.priority === "high" ? chalk2.red : chalk2.gray;
|
|
684
|
+
console.log(` \u25CB ${feat.name} ${pColor(`(${feat.priority})`)}`);
|
|
685
|
+
}
|
|
686
|
+
if (available.length > 5) {
|
|
687
|
+
console.log(chalk2.gray(` ... and ${available.length - 5} more`));
|
|
688
|
+
}
|
|
689
|
+
console.log("");
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function timeSince(isoDate) {
|
|
693
|
+
const then = (/* @__PURE__ */ new Date(isoDate + "Z")).getTime();
|
|
694
|
+
const now = Date.now();
|
|
695
|
+
const diffMs = now - then;
|
|
696
|
+
const mins = Math.floor(diffMs / 6e4);
|
|
697
|
+
if (mins < 60) return `${mins}m`;
|
|
698
|
+
const hours = Math.floor(mins / 60);
|
|
699
|
+
const remainMins = mins % 60;
|
|
700
|
+
return `${hours}h${remainMins > 0 ? String(remainMins).padStart(2, "0") : ""}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/commands/claim.ts
|
|
704
|
+
import chalk3 from "chalk";
|
|
705
|
+
import { randomUUID } from "crypto";
|
|
706
|
+
async function claimCommand(featureIdOrName, options) {
|
|
707
|
+
const db = await openDb();
|
|
708
|
+
const feature = queryOne(
|
|
709
|
+
db,
|
|
710
|
+
`SELECT id, name, status FROM features
|
|
711
|
+
WHERE id = ? OR name = ? OR name LIKE ?`,
|
|
712
|
+
[featureIdOrName, featureIdOrName, `%${featureIdOrName}%`]
|
|
713
|
+
);
|
|
714
|
+
if (!feature) {
|
|
715
|
+
console.log(chalk3.red(`
|
|
716
|
+
Feature "${featureIdOrName}" not found.
|
|
717
|
+
`));
|
|
718
|
+
console.log(chalk3.gray(" Add it with: groundctl add feature -n '" + featureIdOrName + "'"));
|
|
719
|
+
closeDb();
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
if (feature.status === "done") {
|
|
723
|
+
console.log(chalk3.yellow(`
|
|
724
|
+
Feature "${feature.name}" is already done.
|
|
725
|
+
`));
|
|
726
|
+
closeDb();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const existingClaim = queryOne(
|
|
730
|
+
db,
|
|
731
|
+
`SELECT session_id, claimed_at FROM claims
|
|
732
|
+
WHERE feature_id = ? AND released_at IS NULL`,
|
|
733
|
+
[feature.id]
|
|
734
|
+
);
|
|
735
|
+
if (existingClaim) {
|
|
736
|
+
console.log(
|
|
737
|
+
chalk3.red(`
|
|
738
|
+
Feature "${feature.name}" is already claimed by session ${existingClaim.session_id}`)
|
|
739
|
+
);
|
|
740
|
+
const alternatives = query(
|
|
741
|
+
db,
|
|
742
|
+
`SELECT id, name FROM features
|
|
743
|
+
WHERE status = 'pending'
|
|
744
|
+
AND id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
745
|
+
ORDER BY CASE priority
|
|
746
|
+
WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
747
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3
|
|
748
|
+
END
|
|
749
|
+
LIMIT 3`
|
|
750
|
+
);
|
|
751
|
+
if (alternatives.length > 0) {
|
|
752
|
+
console.log(chalk3.gray("\n Available instead:"));
|
|
753
|
+
for (const alt of alternatives) {
|
|
754
|
+
console.log(chalk3.gray(` \u25CB ${alt.name}`));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
console.log("");
|
|
758
|
+
closeDb();
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
const sessionId = options.session ?? randomUUID().slice(0, 8);
|
|
762
|
+
const sessionExists = queryOne(
|
|
763
|
+
db,
|
|
764
|
+
"SELECT id FROM sessions WHERE id = ?",
|
|
765
|
+
[sessionId]
|
|
766
|
+
);
|
|
767
|
+
if (!sessionExists) {
|
|
768
|
+
db.run(
|
|
769
|
+
"INSERT INTO sessions (id, agent, started_at) VALUES (?, 'claude-code', datetime('now'))",
|
|
770
|
+
[sessionId]
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
db.run(
|
|
774
|
+
"INSERT INTO claims (feature_id, session_id) VALUES (?, ?)",
|
|
775
|
+
[feature.id, sessionId]
|
|
776
|
+
);
|
|
777
|
+
db.run(
|
|
778
|
+
"UPDATE features SET status = 'in_progress', session_claimed = ?, claimed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
|
|
779
|
+
[sessionId, feature.id]
|
|
780
|
+
);
|
|
781
|
+
saveDb();
|
|
782
|
+
closeDb();
|
|
783
|
+
console.log(
|
|
784
|
+
chalk3.green(`
|
|
785
|
+
\u2713 Claimed "${feature.name}" \u2192 session ${sessionId}
|
|
786
|
+
`)
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
async function completeCommand(featureIdOrName) {
|
|
790
|
+
const db = await openDb();
|
|
791
|
+
const feature = queryOne(
|
|
792
|
+
db,
|
|
793
|
+
`SELECT id, name, status FROM features
|
|
794
|
+
WHERE id = ? OR name = ? OR name LIKE ?`,
|
|
795
|
+
[featureIdOrName, featureIdOrName, `%${featureIdOrName}%`]
|
|
796
|
+
);
|
|
797
|
+
if (!feature) {
|
|
798
|
+
console.log(chalk3.red(`
|
|
799
|
+
Feature "${featureIdOrName}" not found.
|
|
800
|
+
`));
|
|
801
|
+
closeDb();
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
db.run(
|
|
805
|
+
"UPDATE claims SET released_at = datetime('now') WHERE feature_id = ? AND released_at IS NULL",
|
|
806
|
+
[feature.id]
|
|
807
|
+
);
|
|
808
|
+
db.run(
|
|
809
|
+
"UPDATE features SET status = 'done', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
|
|
810
|
+
[feature.id]
|
|
811
|
+
);
|
|
812
|
+
saveDb();
|
|
813
|
+
closeDb();
|
|
814
|
+
console.log(chalk3.green(`
|
|
815
|
+
\u2713 Completed "${feature.name}"
|
|
816
|
+
`));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/commands/sync.ts
|
|
820
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
821
|
+
import { join as join4 } from "path";
|
|
822
|
+
import chalk4 from "chalk";
|
|
823
|
+
async function syncCommand() {
|
|
824
|
+
const db = await openDb();
|
|
825
|
+
const projectName = process.cwd().split("/").pop() ?? "unknown";
|
|
826
|
+
const projectState = generateProjectState(db, projectName);
|
|
827
|
+
const agentsMd = generateAgentsMd(db, projectName);
|
|
828
|
+
closeDb();
|
|
829
|
+
const cwd = process.cwd();
|
|
830
|
+
writeFileSync3(join4(cwd, "PROJECT_STATE.md"), projectState);
|
|
831
|
+
writeFileSync3(join4(cwd, "AGENTS.md"), agentsMd);
|
|
832
|
+
console.log(chalk4.green("\n \u2713 PROJECT_STATE.md regenerated"));
|
|
833
|
+
console.log(chalk4.green(" \u2713 AGENTS.md regenerated\n"));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/commands/next.ts
|
|
837
|
+
import chalk5 from "chalk";
|
|
838
|
+
async function nextCommand() {
|
|
839
|
+
const db = await openDb();
|
|
840
|
+
const available = query(
|
|
841
|
+
db,
|
|
842
|
+
`SELECT f.id, f.name, f.priority, f.description
|
|
843
|
+
FROM features f
|
|
844
|
+
WHERE f.status = 'pending'
|
|
845
|
+
AND f.id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
846
|
+
ORDER BY
|
|
847
|
+
CASE f.priority
|
|
848
|
+
WHEN 'critical' THEN 0
|
|
849
|
+
WHEN 'high' THEN 1
|
|
850
|
+
WHEN 'medium' THEN 2
|
|
851
|
+
WHEN 'low' THEN 3
|
|
852
|
+
END
|
|
853
|
+
LIMIT 5`
|
|
854
|
+
);
|
|
855
|
+
closeDb();
|
|
856
|
+
if (available.length === 0) {
|
|
857
|
+
console.log(chalk5.yellow("\n No available features to claim.\n"));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
console.log(chalk5.bold("\n Next available features:\n"));
|
|
861
|
+
for (let i = 0; i < available.length; i++) {
|
|
862
|
+
const feat = available[i];
|
|
863
|
+
const pColor = feat.priority === "critical" || feat.priority === "high" ? chalk5.red : chalk5.gray;
|
|
864
|
+
const marker = i === 0 ? chalk5.green("\u2192") : " ";
|
|
865
|
+
console.log(` ${marker} ${feat.name} ${pColor(`(${feat.priority})`)}`);
|
|
866
|
+
if (feat.description) {
|
|
867
|
+
console.log(chalk5.gray(` ${feat.description}`));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
console.log(chalk5.gray(`
|
|
871
|
+
Claim with: groundctl claim "${available[0].name}"
|
|
872
|
+
`));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/commands/log.ts
|
|
876
|
+
import chalk6 from "chalk";
|
|
877
|
+
async function logCommand(options) {
|
|
878
|
+
const db = await openDb();
|
|
879
|
+
if (options.session) {
|
|
880
|
+
const session = queryOne(db, "SELECT * FROM sessions WHERE id = ? OR id LIKE ?", [
|
|
881
|
+
options.session,
|
|
882
|
+
`%${options.session}%`
|
|
883
|
+
]);
|
|
884
|
+
if (!session) {
|
|
885
|
+
console.log(chalk6.red(`
|
|
886
|
+
Session "${options.session}" not found.
|
|
887
|
+
`));
|
|
888
|
+
closeDb();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
console.log(chalk6.bold(`
|
|
892
|
+
Session ${session.id}`));
|
|
893
|
+
console.log(chalk6.gray(` Agent: ${session.agent}`));
|
|
894
|
+
console.log(chalk6.gray(` Started: ${session.started_at}`));
|
|
895
|
+
if (session.ended_at) {
|
|
896
|
+
console.log(chalk6.gray(` Ended: ${session.ended_at}`));
|
|
897
|
+
}
|
|
898
|
+
if (session.summary) {
|
|
899
|
+
console.log(`
|
|
900
|
+
${session.summary}`);
|
|
901
|
+
}
|
|
902
|
+
const decisions = query(
|
|
903
|
+
db,
|
|
904
|
+
"SELECT description, rationale FROM decisions WHERE session_id = ?",
|
|
905
|
+
[session.id]
|
|
906
|
+
);
|
|
907
|
+
if (decisions.length > 0) {
|
|
908
|
+
console.log(chalk6.bold("\n Decisions:"));
|
|
909
|
+
for (const d of decisions) {
|
|
910
|
+
console.log(` \u2022 ${d.description}`);
|
|
911
|
+
if (d.rationale) {
|
|
912
|
+
console.log(chalk6.gray(` ${d.rationale}`));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const files = query(
|
|
917
|
+
db,
|
|
918
|
+
"SELECT path, operation, lines_changed FROM files_modified WHERE session_id = ? ORDER BY created_at",
|
|
919
|
+
[session.id]
|
|
920
|
+
);
|
|
921
|
+
if (files.length > 0) {
|
|
922
|
+
console.log(chalk6.bold(`
|
|
923
|
+
Files modified (${files.length}):`));
|
|
924
|
+
for (const f of files) {
|
|
925
|
+
const op = f.operation === "created" ? chalk6.green("+") : f.operation === "deleted" ? chalk6.red("-") : chalk6.yellow("~");
|
|
926
|
+
console.log(` ${op} ${f.path} (${f.lines_changed} lines)`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
console.log("");
|
|
930
|
+
} else {
|
|
931
|
+
const sessions = query(db, "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 20");
|
|
932
|
+
if (sessions.length === 0) {
|
|
933
|
+
console.log(chalk6.yellow("\n No sessions recorded yet.\n"));
|
|
934
|
+
closeDb();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
console.log(chalk6.bold("\n Session timeline:\n"));
|
|
938
|
+
for (const s of sessions) {
|
|
939
|
+
const status = s.ended_at ? chalk6.green("done") : chalk6.yellow("active");
|
|
940
|
+
console.log(
|
|
941
|
+
` ${chalk6.bold(s.id)} ${chalk6.gray(s.started_at)} ${status} ${chalk6.gray(s.agent)}`
|
|
942
|
+
);
|
|
943
|
+
if (s.summary) {
|
|
944
|
+
console.log(chalk6.gray(` ${s.summary}`));
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
console.log("");
|
|
948
|
+
}
|
|
949
|
+
closeDb();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/commands/add.ts
|
|
953
|
+
import chalk7 from "chalk";
|
|
954
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
955
|
+
async function addCommand(type, options) {
|
|
956
|
+
const db = await openDb();
|
|
957
|
+
if (type === "feature") {
|
|
958
|
+
if (!options.name) {
|
|
959
|
+
console.log(chalk7.red("\n --name is required for features.\n"));
|
|
960
|
+
closeDb();
|
|
961
|
+
process.exit(1);
|
|
962
|
+
}
|
|
963
|
+
const id = options.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
964
|
+
const priority = options.priority ?? "medium";
|
|
965
|
+
db.run(
|
|
966
|
+
"INSERT INTO features (id, name, priority, description) VALUES (?, ?, ?, ?)",
|
|
967
|
+
[id, options.name, priority, options.description ?? null]
|
|
968
|
+
);
|
|
969
|
+
saveDb();
|
|
970
|
+
closeDb();
|
|
971
|
+
console.log(chalk7.green(`
|
|
972
|
+
\u2713 Feature added: ${options.name} (${priority})
|
|
973
|
+
`));
|
|
974
|
+
} else if (type === "session") {
|
|
975
|
+
const id = options.name ?? randomUUID2().slice(0, 8);
|
|
976
|
+
const agent = options.agent ?? "claude-code";
|
|
977
|
+
db.run("INSERT INTO sessions (id, agent) VALUES (?, ?)", [id, agent]);
|
|
978
|
+
saveDb();
|
|
979
|
+
closeDb();
|
|
980
|
+
console.log(chalk7.green(`
|
|
981
|
+
\u2713 Session created: ${id} (${agent})
|
|
982
|
+
`));
|
|
983
|
+
} else {
|
|
984
|
+
console.log(chalk7.red(`
|
|
985
|
+
Unknown type "${type}". Use "feature" or "session".
|
|
986
|
+
`));
|
|
987
|
+
closeDb();
|
|
988
|
+
process.exit(1);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/commands/ingest.ts
|
|
993
|
+
import { existsSync as existsSync4, readdirSync } from "fs";
|
|
994
|
+
import { join as join5, resolve } from "path";
|
|
995
|
+
import { homedir as homedir2 } from "os";
|
|
996
|
+
import chalk8 from "chalk";
|
|
997
|
+
|
|
998
|
+
// src/ingest/claude-parser.ts
|
|
999
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1000
|
+
var DECISION_PATTERNS = [
|
|
1001
|
+
/\bI(?:'m going to| decided| chose| picked| went with| opted for)\b(.{10,120})/i,
|
|
1002
|
+
/\bgoing with\b(.{5,100})/i,
|
|
1003
|
+
/\bchose?\s+(\w[\w\s-]+)\s+(?:over|instead of|rather than)\b(.{0,80})/i,
|
|
1004
|
+
/\btradeoff[:\s]+(.{10,120})/i,
|
|
1005
|
+
/\bdecision[:\s]+(.{10,120})/i,
|
|
1006
|
+
/\brationale[:\s]+(.{10,120})/i,
|
|
1007
|
+
/\bbecause\s+(.{10,100})\s+(?:—|\.)/i,
|
|
1008
|
+
/\bswitched?\s+(?:from\s+\S+\s+)?to\s+(.{5,80})\s+(?:—|because|since|for)/i
|
|
1009
|
+
];
|
|
1010
|
+
var PROJECT_PATH_RE = /^\/[^\s'"]+\.[a-zA-Z0-9]{1,10}$/;
|
|
1011
|
+
function isFilePath(s) {
|
|
1012
|
+
return PROJECT_PATH_RE.test(s) && !s.includes("*") && !s.includes("?");
|
|
1013
|
+
}
|
|
1014
|
+
function countContentLines(content) {
|
|
1015
|
+
return content.split("\n").length;
|
|
1016
|
+
}
|
|
1017
|
+
function parseTranscript(transcriptPath, sessionId, projectPath) {
|
|
1018
|
+
const raw = readFileSync4(transcriptPath, "utf-8");
|
|
1019
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
1020
|
+
const parsed = [];
|
|
1021
|
+
for (const line of lines) {
|
|
1022
|
+
try {
|
|
1023
|
+
parsed.push(JSON.parse(line));
|
|
1024
|
+
} catch {
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const timestamps = [];
|
|
1028
|
+
for (const entry of parsed) {
|
|
1029
|
+
if (entry.timestamp) timestamps.push(entry.timestamp);
|
|
1030
|
+
}
|
|
1031
|
+
const startedAt = timestamps[0] ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1032
|
+
const endedAt = timestamps[timestamps.length - 1] ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1033
|
+
const toolResults = /* @__PURE__ */ new Map();
|
|
1034
|
+
for (const entry of parsed) {
|
|
1035
|
+
if (entry.type !== "user") continue;
|
|
1036
|
+
const content = entry.message?.content ?? [];
|
|
1037
|
+
for (const block of content) {
|
|
1038
|
+
if (block.type === "tool_result") {
|
|
1039
|
+
toolResults.set(block.tool_use_id, block);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const filesMap = /* @__PURE__ */ new Map();
|
|
1044
|
+
const commits = [];
|
|
1045
|
+
const decisions = [];
|
|
1046
|
+
let lastAssistantText = "";
|
|
1047
|
+
for (const entry of parsed) {
|
|
1048
|
+
if (entry.type !== "assistant") continue;
|
|
1049
|
+
const content = entry.message?.content ?? [];
|
|
1050
|
+
for (const block of content) {
|
|
1051
|
+
if (block.type === "text") {
|
|
1052
|
+
const text = block.text;
|
|
1053
|
+
if (text.length > 20) {
|
|
1054
|
+
lastAssistantText = text;
|
|
1055
|
+
extractDecisions(text, decisions);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (block.type === "tool_use") {
|
|
1059
|
+
const tool = block;
|
|
1060
|
+
const result = toolResults.get(tool.id);
|
|
1061
|
+
const succeeded = result ? !result.is_error : true;
|
|
1062
|
+
if (tool.name === "Write") {
|
|
1063
|
+
const filePath = tool.input.file_path;
|
|
1064
|
+
if (filePath && isFilePath(filePath)) {
|
|
1065
|
+
const content2 = tool.input.content;
|
|
1066
|
+
const lines2 = content2 ? countContentLines(content2) : 0;
|
|
1067
|
+
const rel = relativePath(filePath, projectPath);
|
|
1068
|
+
if (!filesMap.has(rel)) {
|
|
1069
|
+
filesMap.set(rel, {
|
|
1070
|
+
path: rel,
|
|
1071
|
+
operation: "created",
|
|
1072
|
+
linesChanged: lines2
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (tool.name === "Edit" && succeeded) {
|
|
1078
|
+
const filePath = tool.input.file_path;
|
|
1079
|
+
if (filePath && isFilePath(filePath)) {
|
|
1080
|
+
const rel = relativePath(filePath, projectPath);
|
|
1081
|
+
const existing = filesMap.get(rel);
|
|
1082
|
+
const oldStr = tool.input.old_string;
|
|
1083
|
+
const newStr = tool.input.new_string;
|
|
1084
|
+
const lines2 = Math.max(
|
|
1085
|
+
oldStr ? countContentLines(oldStr) : 0,
|
|
1086
|
+
newStr ? countContentLines(newStr) : 0
|
|
1087
|
+
);
|
|
1088
|
+
if (existing) {
|
|
1089
|
+
existing.linesChanged += lines2;
|
|
1090
|
+
} else {
|
|
1091
|
+
filesMap.set(rel, {
|
|
1092
|
+
path: rel,
|
|
1093
|
+
operation: "modified",
|
|
1094
|
+
linesChanged: lines2
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (tool.name === "Bash") {
|
|
1100
|
+
const command = tool.input.command;
|
|
1101
|
+
if (!command) continue;
|
|
1102
|
+
if (succeeded && command.includes("git commit")) {
|
|
1103
|
+
const msg = extractCommitMessage(command);
|
|
1104
|
+
if (msg) {
|
|
1105
|
+
commits.push({ message: msg, command: command.slice(0, 100) });
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (succeeded) {
|
|
1109
|
+
extractBashFileOps(command, filesMap, projectPath);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const seenDecisions = /* @__PURE__ */ new Set();
|
|
1116
|
+
const uniqueDecisions = decisions.filter((d) => {
|
|
1117
|
+
const key = d.description.slice(0, 40).toLowerCase();
|
|
1118
|
+
if (seenDecisions.has(key)) return false;
|
|
1119
|
+
seenDecisions.add(key);
|
|
1120
|
+
return true;
|
|
1121
|
+
});
|
|
1122
|
+
const summary = buildSummary(
|
|
1123
|
+
sessionId,
|
|
1124
|
+
filesMap.size,
|
|
1125
|
+
commits.length,
|
|
1126
|
+
uniqueDecisions.length,
|
|
1127
|
+
lastAssistantText
|
|
1128
|
+
);
|
|
1129
|
+
return {
|
|
1130
|
+
sessionId,
|
|
1131
|
+
startedAt,
|
|
1132
|
+
endedAt,
|
|
1133
|
+
filesModified: Array.from(filesMap.values()),
|
|
1134
|
+
commits,
|
|
1135
|
+
decisions: uniqueDecisions,
|
|
1136
|
+
summary,
|
|
1137
|
+
agent: "claude-code"
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
function relativePath(abs, projectPath) {
|
|
1141
|
+
if (!projectPath) return abs;
|
|
1142
|
+
if (abs.startsWith(projectPath)) {
|
|
1143
|
+
return abs.slice(projectPath.length).replace(/^\//, "");
|
|
1144
|
+
}
|
|
1145
|
+
return abs;
|
|
1146
|
+
}
|
|
1147
|
+
function extractCommitMessage(command) {
|
|
1148
|
+
const heredocMatch = command.match(/git commit -m "\$\(cat <<'EOF'\n([\s\S]+?)\nEOF/);
|
|
1149
|
+
if (heredocMatch) {
|
|
1150
|
+
return heredocMatch[1].split("\n")[0].trim();
|
|
1151
|
+
}
|
|
1152
|
+
const simpleMatch = command.match(/git commit -m ["']([^"']+)["']/);
|
|
1153
|
+
if (simpleMatch) return simpleMatch[1].trim();
|
|
1154
|
+
const mMatch = command.match(/git commit.*?-m\s+"([^"]+)"/);
|
|
1155
|
+
if (mMatch) return mMatch[1].trim();
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
function extractBashFileOps(command, filesMap, projectPath) {
|
|
1159
|
+
const rmMatches = command.matchAll(/\brm\s+(?:-[rf]+\s+)?([^\s;&|]+\.[a-zA-Z0-9]+)/g);
|
|
1160
|
+
for (const m of rmMatches) {
|
|
1161
|
+
if (isFilePath(m[1])) {
|
|
1162
|
+
const rel = relativePath(m[1], projectPath);
|
|
1163
|
+
filesMap.set(rel, { path: rel, operation: "deleted", linesChanged: 0 });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
var SKIP_DECISION_PHRASES = /* @__PURE__ */ new Set([
|
|
1168
|
+
"i decided to read",
|
|
1169
|
+
"i decided to check",
|
|
1170
|
+
"i decided to look",
|
|
1171
|
+
"i'm going to read",
|
|
1172
|
+
"i'm going to check",
|
|
1173
|
+
"i'm going to look",
|
|
1174
|
+
"i'm going to run",
|
|
1175
|
+
"i'm going to use",
|
|
1176
|
+
"let me",
|
|
1177
|
+
"going with npm"
|
|
1178
|
+
]);
|
|
1179
|
+
function extractDecisions(text, decisions) {
|
|
1180
|
+
for (const pattern of DECISION_PATTERNS) {
|
|
1181
|
+
const matches = text.matchAll(new RegExp(pattern.source, "gi"));
|
|
1182
|
+
for (const m of matches) {
|
|
1183
|
+
const raw = m[1]?.trim() ?? "";
|
|
1184
|
+
if (raw.length < 10 || raw.length > 200) continue;
|
|
1185
|
+
const lower = raw.toLowerCase();
|
|
1186
|
+
if (SKIP_DECISION_PHRASES.has(lower.slice(0, 30))) continue;
|
|
1187
|
+
if (lower.startsWith("the ") && lower.length < 20) continue;
|
|
1188
|
+
if (/^(it|this|that|to |the file|a |an )/.test(lower) && lower.length < 25) continue;
|
|
1189
|
+
const matchIndex = m.index ?? 0;
|
|
1190
|
+
const surrounding = text.slice(
|
|
1191
|
+
Math.max(0, matchIndex + m[0].length),
|
|
1192
|
+
Math.min(text.length, matchIndex + m[0].length + 200)
|
|
1193
|
+
);
|
|
1194
|
+
const rationale = extractRationale(surrounding);
|
|
1195
|
+
decisions.push({
|
|
1196
|
+
description: clean(raw),
|
|
1197
|
+
rationale,
|
|
1198
|
+
confidence: rationale ? "high" : "low"
|
|
1199
|
+
});
|
|
1200
|
+
if (decisions.length >= 20) return;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
function extractRationale(text) {
|
|
1205
|
+
const match = text.match(
|
|
1206
|
+
/^[^.!?]*(?:because|since|—|:)\s*([^.!?\n]{15,120})/i
|
|
1207
|
+
);
|
|
1208
|
+
if (match) return clean(match[1]);
|
|
1209
|
+
const first = text.split(/[.!?\n]/)[0]?.trim();
|
|
1210
|
+
if (first && first.length > 15 && first.length < 120 && !first.match(/^(Let|I |Now|Next|This )/)) {
|
|
1211
|
+
return clean(first);
|
|
1212
|
+
}
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
function clean(s) {
|
|
1216
|
+
return s.replace(/\s+/g, " ").replace(/^[.,;:\-—\s]+/, "").replace(/[.,;:\-—\s]+$/, "").trim();
|
|
1217
|
+
}
|
|
1218
|
+
function buildSummary(sessionId, fileCount, commitCount, decisionCount, lastText) {
|
|
1219
|
+
const firstLine = lastText.split("\n")[0]?.trim() ?? "";
|
|
1220
|
+
if (firstLine.length > 20 && firstLine.length < 200 && !firstLine.includes("```")) {
|
|
1221
|
+
return firstLine;
|
|
1222
|
+
}
|
|
1223
|
+
return `Session ${sessionId}: ${fileCount} files, ${commitCount} commits, ${decisionCount} decisions`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/commands/ingest.ts
|
|
1227
|
+
function findLatestTranscript(projectPath) {
|
|
1228
|
+
const projectsDir = join5(homedir2(), ".claude", "projects");
|
|
1229
|
+
if (!existsSync4(projectsDir)) return null;
|
|
1230
|
+
const projectKey = projectPath.replace(/\//g, "-");
|
|
1231
|
+
let transcriptDir = null;
|
|
1232
|
+
const directMatch = join5(projectsDir, projectKey);
|
|
1233
|
+
if (existsSync4(directMatch)) {
|
|
1234
|
+
transcriptDir = directMatch;
|
|
1235
|
+
} else {
|
|
1236
|
+
const projectName = projectPath.split("/").pop() ?? "";
|
|
1237
|
+
const dirs = readdirSync(projectsDir);
|
|
1238
|
+
for (const d of dirs) {
|
|
1239
|
+
if (d.endsWith(`-${projectName}`) || d.includes(projectName.replace(/\//g, "-"))) {
|
|
1240
|
+
transcriptDir = join5(projectsDir, d);
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (!transcriptDir || !existsSync4(transcriptDir)) return null;
|
|
1246
|
+
const jsonlFiles = readdirSync(transcriptDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({
|
|
1247
|
+
name: f,
|
|
1248
|
+
path: join5(transcriptDir, f)
|
|
1249
|
+
}));
|
|
1250
|
+
if (jsonlFiles.length === 0) return null;
|
|
1251
|
+
jsonlFiles.sort((a, b) => b.name.localeCompare(a.name));
|
|
1252
|
+
return jsonlFiles[0].path;
|
|
1253
|
+
}
|
|
1254
|
+
async function ingestCommand(options) {
|
|
1255
|
+
const projectPath = options.projectPath ? resolve(options.projectPath) : process.cwd();
|
|
1256
|
+
const source = options.source ?? "claude-code";
|
|
1257
|
+
let transcriptPath = options.transcript;
|
|
1258
|
+
if (!transcriptPath) {
|
|
1259
|
+
transcriptPath = findLatestTranscript(projectPath) ?? void 0;
|
|
1260
|
+
}
|
|
1261
|
+
if (!transcriptPath || !existsSync4(transcriptPath)) {
|
|
1262
|
+
console.log(chalk8.yellow("\n No transcript found. Skipping ingest.\n"));
|
|
1263
|
+
if (!options.noSync) await syncCommand();
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
console.log(chalk8.gray(`
|
|
1267
|
+
Parsing transcript: ${transcriptPath.split("/").slice(-2).join("/")}`));
|
|
1268
|
+
const parsed = parseTranscript(transcriptPath, options.sessionId ?? "auto", projectPath);
|
|
1269
|
+
const db = await openDb();
|
|
1270
|
+
const sessionId = options.sessionId ?? parsed.sessionId;
|
|
1271
|
+
const sessionExists = queryOne(db, "SELECT id FROM sessions WHERE id = ?", [sessionId]);
|
|
1272
|
+
if (sessionExists) {
|
|
1273
|
+
db.run(
|
|
1274
|
+
"UPDATE sessions SET ended_at = ?, summary = ? WHERE id = ?",
|
|
1275
|
+
[parsed.endedAt, parsed.summary, sessionId]
|
|
1276
|
+
);
|
|
1277
|
+
} else {
|
|
1278
|
+
db.run(
|
|
1279
|
+
"INSERT INTO sessions (id, agent, started_at, ended_at, summary) VALUES (?, ?, ?, ?, ?)",
|
|
1280
|
+
[sessionId, source, parsed.startedAt, parsed.endedAt, parsed.summary]
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
let newFiles = 0;
|
|
1284
|
+
for (const file of parsed.filesModified) {
|
|
1285
|
+
const exists = queryOne(
|
|
1286
|
+
db,
|
|
1287
|
+
"SELECT id FROM files_modified WHERE session_id = ? AND path = ?",
|
|
1288
|
+
[sessionId, file.path]
|
|
1289
|
+
);
|
|
1290
|
+
if (!exists) {
|
|
1291
|
+
db.run(
|
|
1292
|
+
"INSERT INTO files_modified (session_id, path, operation, lines_changed) VALUES (?, ?, ?, ?)",
|
|
1293
|
+
[sessionId, file.path, file.operation, file.linesChanged]
|
|
1294
|
+
);
|
|
1295
|
+
newFiles++;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
let newDecisions = 0;
|
|
1299
|
+
for (const d of parsed.decisions) {
|
|
1300
|
+
const exists = queryOne(
|
|
1301
|
+
db,
|
|
1302
|
+
"SELECT id FROM decisions WHERE session_id = ? AND description = ?",
|
|
1303
|
+
[sessionId, d.description]
|
|
1304
|
+
);
|
|
1305
|
+
if (!exists) {
|
|
1306
|
+
db.run(
|
|
1307
|
+
"INSERT INTO decisions (session_id, description, rationale) VALUES (?, ?, ?)",
|
|
1308
|
+
[sessionId, d.description, d.rationale ?? null]
|
|
1309
|
+
);
|
|
1310
|
+
newDecisions++;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
saveDb();
|
|
1314
|
+
closeDb();
|
|
1315
|
+
console.log(
|
|
1316
|
+
chalk8.green(
|
|
1317
|
+
` \u2713 Ingested session ${sessionId}: ${newFiles} files, ${parsed.commits.length} commits, ${newDecisions} decisions`
|
|
1318
|
+
)
|
|
1319
|
+
);
|
|
1320
|
+
if (parsed.decisions.length > 0 && newDecisions > 0) {
|
|
1321
|
+
console.log(chalk8.gray(`
|
|
1322
|
+
Decisions captured:`));
|
|
1323
|
+
for (const d of parsed.decisions.slice(0, 5)) {
|
|
1324
|
+
const conf = d.confidence === "low" ? chalk8.gray(" (low confidence)") : "";
|
|
1325
|
+
console.log(chalk8.gray(` \u2022 ${d.description.slice(0, 80)}${conf}`));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (!options.noSync) {
|
|
1329
|
+
console.log("");
|
|
1330
|
+
await syncCommand();
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/commands/report.ts
|
|
1335
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
1336
|
+
import { join as join6 } from "path";
|
|
1337
|
+
import chalk9 from "chalk";
|
|
1338
|
+
function formatDuration(start, end) {
|
|
1339
|
+
if (!end) return "ongoing";
|
|
1340
|
+
const startMs = new Date(start).getTime();
|
|
1341
|
+
const endMs = new Date(end).getTime();
|
|
1342
|
+
if (isNaN(startMs) || isNaN(endMs)) return "unknown";
|
|
1343
|
+
const ms = endMs - startMs;
|
|
1344
|
+
const mins = Math.floor(ms / 6e4);
|
|
1345
|
+
if (mins < 60) return `${mins}min`;
|
|
1346
|
+
const h = Math.floor(mins / 60);
|
|
1347
|
+
const m = mins % 60;
|
|
1348
|
+
return m > 0 ? `${h}h${m}min` : `${h}h`;
|
|
1349
|
+
}
|
|
1350
|
+
function buildSessionReport(sessionRow, files, decisions, completedFeatures, nextAvailable, activeClaims) {
|
|
1351
|
+
const date = sessionRow.started_at.slice(0, 10);
|
|
1352
|
+
const duration = formatDuration(sessionRow.started_at, sessionRow.ended_at);
|
|
1353
|
+
let md = `# Session ${sessionRow.id} \u2014 ${date}
|
|
1354
|
+
`;
|
|
1355
|
+
md += `Date: ${date} | Duration: ${duration} | Agent: ${sessionRow.agent}
|
|
1356
|
+
|
|
1357
|
+
`;
|
|
1358
|
+
if (sessionRow.summary) {
|
|
1359
|
+
md += `## Summary
|
|
1360
|
+
${sessionRow.summary}
|
|
1361
|
+
|
|
1362
|
+
`;
|
|
1363
|
+
}
|
|
1364
|
+
if (completedFeatures.length > 0) {
|
|
1365
|
+
md += "## What was built\n";
|
|
1366
|
+
for (const f of completedFeatures) {
|
|
1367
|
+
md += `- ${f.name}
|
|
1368
|
+
`;
|
|
1369
|
+
}
|
|
1370
|
+
md += "\n";
|
|
1371
|
+
}
|
|
1372
|
+
if (files.length > 0) {
|
|
1373
|
+
const created = files.filter((f) => f.operation === "created");
|
|
1374
|
+
const modified = files.filter((f) => f.operation === "modified");
|
|
1375
|
+
const deleted = files.filter((f) => f.operation === "deleted");
|
|
1376
|
+
md += `## Files touched (${files.length})
|
|
1377
|
+
`;
|
|
1378
|
+
for (const f of created) {
|
|
1379
|
+
md += `- ${f.path} (created, ${f.lines_changed} lines)
|
|
1380
|
+
`;
|
|
1381
|
+
}
|
|
1382
|
+
for (const f of modified) {
|
|
1383
|
+
md += `- ${f.path} (modified, ${f.lines_changed} lines)
|
|
1384
|
+
`;
|
|
1385
|
+
}
|
|
1386
|
+
for (const f of deleted) {
|
|
1387
|
+
md += `- ${f.path} (deleted)
|
|
1388
|
+
`;
|
|
1389
|
+
}
|
|
1390
|
+
md += "\n";
|
|
1391
|
+
}
|
|
1392
|
+
if (decisions.length > 0) {
|
|
1393
|
+
md += "## Decisions\n";
|
|
1394
|
+
for (const d of decisions) {
|
|
1395
|
+
md += `- ${d.description}`;
|
|
1396
|
+
if (d.rationale) md += ` \u2014 ${d.rationale}`;
|
|
1397
|
+
md += "\n";
|
|
1398
|
+
}
|
|
1399
|
+
md += "\n";
|
|
1400
|
+
}
|
|
1401
|
+
if (activeClaims.length > 0) {
|
|
1402
|
+
md += "## In progress\n";
|
|
1403
|
+
for (const c of activeClaims) {
|
|
1404
|
+
md += `- ${c.feature_name} (session ${c.session_id})
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
md += "\n";
|
|
1408
|
+
}
|
|
1409
|
+
if (nextAvailable.length > 0) {
|
|
1410
|
+
md += "## Next available\n";
|
|
1411
|
+
for (const f of nextAvailable.slice(0, 5)) {
|
|
1412
|
+
md += `- feature/${f.name.toLowerCase().replace(/\s+/g, "-")}
|
|
1413
|
+
`;
|
|
1414
|
+
}
|
|
1415
|
+
md += "\n";
|
|
1416
|
+
}
|
|
1417
|
+
return md;
|
|
1418
|
+
}
|
|
1419
|
+
async function reportCommand(options) {
|
|
1420
|
+
const db = await openDb();
|
|
1421
|
+
const cwd = process.cwd();
|
|
1422
|
+
let sessions;
|
|
1423
|
+
if (options.all) {
|
|
1424
|
+
sessions = query(db, "SELECT * FROM sessions ORDER BY started_at");
|
|
1425
|
+
} else if (options.session) {
|
|
1426
|
+
const s = queryOne(
|
|
1427
|
+
db,
|
|
1428
|
+
"SELECT * FROM sessions WHERE id = ? OR id LIKE ?",
|
|
1429
|
+
[options.session, `%${options.session}%`]
|
|
1430
|
+
);
|
|
1431
|
+
if (!s) {
|
|
1432
|
+
console.log(chalk9.red(`
|
|
1433
|
+
Session "${options.session}" not found.
|
|
1434
|
+
`));
|
|
1435
|
+
closeDb();
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
sessions = [s];
|
|
1439
|
+
} else {
|
|
1440
|
+
const s = queryOne(
|
|
1441
|
+
db,
|
|
1442
|
+
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT 1"
|
|
1443
|
+
);
|
|
1444
|
+
if (!s) {
|
|
1445
|
+
console.log(chalk9.yellow("\n No sessions found. Run groundctl init first.\n"));
|
|
1446
|
+
closeDb();
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
sessions = [s];
|
|
1450
|
+
}
|
|
1451
|
+
const nextAvailable = query(
|
|
1452
|
+
db,
|
|
1453
|
+
`SELECT name, status FROM features
|
|
1454
|
+
WHERE status = 'pending'
|
|
1455
|
+
AND id NOT IN (SELECT feature_id FROM claims WHERE released_at IS NULL)
|
|
1456
|
+
ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1
|
|
1457
|
+
WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END
|
|
1458
|
+
LIMIT 5`
|
|
1459
|
+
);
|
|
1460
|
+
const activeClaims = query(
|
|
1461
|
+
db,
|
|
1462
|
+
`SELECT f.name as feature_name, c.session_id
|
|
1463
|
+
FROM claims c JOIN features f ON c.feature_id = f.id
|
|
1464
|
+
WHERE c.released_at IS NULL`
|
|
1465
|
+
);
|
|
1466
|
+
closeDb();
|
|
1467
|
+
if (options.all) {
|
|
1468
|
+
const allDb = await openDb();
|
|
1469
|
+
let fullReport = `# Session History
|
|
1470
|
+
|
|
1471
|
+
`;
|
|
1472
|
+
for (const s of sessions) {
|
|
1473
|
+
const files2 = query(allDb, "SELECT * FROM files_modified WHERE session_id = ?", [s.id]);
|
|
1474
|
+
const decisions2 = query(allDb, "SELECT description, rationale FROM decisions WHERE session_id = ?", [s.id]);
|
|
1475
|
+
const completedFeatures2 = query(
|
|
1476
|
+
allDb,
|
|
1477
|
+
`SELECT f.name, f.status FROM features f
|
|
1478
|
+
JOIN claims c ON c.feature_id = f.id
|
|
1479
|
+
WHERE c.session_id = ? AND f.status = 'done'`,
|
|
1480
|
+
[s.id]
|
|
1481
|
+
);
|
|
1482
|
+
fullReport += buildSessionReport(s, files2, decisions2, completedFeatures2, nextAvailable, []);
|
|
1483
|
+
fullReport += "---\n\n";
|
|
1484
|
+
}
|
|
1485
|
+
closeDb();
|
|
1486
|
+
const outPath2 = join6(cwd, "SESSION_HISTORY.md");
|
|
1487
|
+
writeFileSync4(outPath2, fullReport);
|
|
1488
|
+
console.log(chalk9.green(`
|
|
1489
|
+
\u2713 SESSION_HISTORY.md written (${sessions.length} sessions)
|
|
1490
|
+
`));
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const db2 = await openDb();
|
|
1494
|
+
const session = sessions[0];
|
|
1495
|
+
const files = query(db2, "SELECT * FROM files_modified WHERE session_id = ?", [session.id]);
|
|
1496
|
+
const decisions = query(db2, "SELECT description, rationale FROM decisions WHERE session_id = ?", [session.id]);
|
|
1497
|
+
const completedFeatures = query(
|
|
1498
|
+
db2,
|
|
1499
|
+
`SELECT f.name, f.status FROM features f
|
|
1500
|
+
JOIN claims c ON c.feature_id = f.id
|
|
1501
|
+
WHERE c.session_id = ? AND f.status = 'done'`,
|
|
1502
|
+
[session.id]
|
|
1503
|
+
);
|
|
1504
|
+
closeDb();
|
|
1505
|
+
const report = buildSessionReport(
|
|
1506
|
+
session,
|
|
1507
|
+
files,
|
|
1508
|
+
decisions,
|
|
1509
|
+
completedFeatures,
|
|
1510
|
+
nextAvailable,
|
|
1511
|
+
activeClaims
|
|
1512
|
+
);
|
|
1513
|
+
const outPath = join6(cwd, "SESSION_REPORT.md");
|
|
1514
|
+
writeFileSync4(outPath, report);
|
|
1515
|
+
console.log(chalk9.green(`
|
|
1516
|
+
\u2713 SESSION_REPORT.md written (session ${session.id})
|
|
1517
|
+
`));
|
|
1518
|
+
console.log(chalk9.gray(` ${files.length} files \xB7 ${decisions.length} decisions \xB7 ${completedFeatures.length} features completed`));
|
|
1519
|
+
console.log("");
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/commands/health.ts
|
|
1523
|
+
import chalk10 from "chalk";
|
|
1524
|
+
async function healthCommand() {
|
|
1525
|
+
const db = await openDb();
|
|
1526
|
+
const projectName = process.cwd().split("/").pop() ?? "unknown";
|
|
1527
|
+
const featureCounts = query(
|
|
1528
|
+
db,
|
|
1529
|
+
"SELECT status, COUNT(*) as count FROM features GROUP BY status"
|
|
1530
|
+
);
|
|
1531
|
+
const counts = { pending: 0, in_progress: 0, done: 0, blocked: 0 };
|
|
1532
|
+
for (const row of featureCounts) counts[row.status] = row.count;
|
|
1533
|
+
const total = counts.pending + counts.in_progress + counts.done + counts.blocked;
|
|
1534
|
+
const featurePct = total > 0 ? counts.done / total : 0;
|
|
1535
|
+
const featureScore = Math.round(featurePct * 40);
|
|
1536
|
+
const testFiles = queryOne(
|
|
1537
|
+
db,
|
|
1538
|
+
`SELECT COUNT(*) as count FROM files_modified
|
|
1539
|
+
WHERE path LIKE '%.test.%' OR path LIKE '%.spec.%'
|
|
1540
|
+
OR path LIKE '%/test/%' OR path LIKE '%/tests/%'
|
|
1541
|
+
OR path LIKE '%/__tests__/%'`
|
|
1542
|
+
)?.count ?? 0;
|
|
1543
|
+
const testScore = testFiles > 0 ? Math.min(20, Math.round(testFiles / Math.max(1, total) * 40)) : 0;
|
|
1544
|
+
const decisionCount = queryOne(db, "SELECT COUNT(*) as count FROM decisions")?.count ?? 0;
|
|
1545
|
+
const sessionCount = queryOne(db, "SELECT COUNT(*) as count FROM sessions")?.count ?? 0;
|
|
1546
|
+
const decisionRatio = sessionCount > 0 ? Math.min(1, decisionCount / sessionCount) : 0;
|
|
1547
|
+
const decisionScore = Math.round(decisionRatio * 20);
|
|
1548
|
+
const staleClaims = queryOne(
|
|
1549
|
+
db,
|
|
1550
|
+
`SELECT COUNT(*) as count FROM claims
|
|
1551
|
+
WHERE released_at IS NULL
|
|
1552
|
+
AND datetime(claimed_at, '+24 hours') < datetime('now')`
|
|
1553
|
+
)?.count ?? 0;
|
|
1554
|
+
const claimScore = staleClaims === 0 ? 10 : Math.max(0, 10 - staleClaims * 5);
|
|
1555
|
+
const deployMentions = queryOne(
|
|
1556
|
+
db,
|
|
1557
|
+
`SELECT COUNT(*) as count FROM decisions
|
|
1558
|
+
WHERE lower(description) LIKE '%deploy%'
|
|
1559
|
+
OR lower(description) LIKE '%railway%'
|
|
1560
|
+
OR lower(description) LIKE '%fly.io%'
|
|
1561
|
+
OR lower(description) LIKE '%heroku%'`
|
|
1562
|
+
)?.count ?? 0;
|
|
1563
|
+
const deployFiles = queryOne(
|
|
1564
|
+
db,
|
|
1565
|
+
`SELECT COUNT(*) as count FROM files_modified
|
|
1566
|
+
WHERE lower(path) LIKE '%railway%' OR lower(path) LIKE '%fly.toml%'
|
|
1567
|
+
OR lower(path) LIKE '%heroku%' OR lower(path) LIKE 'procfile%'
|
|
1568
|
+
OR lower(path) LIKE '%dockerfile%' OR lower(path) LIKE '%deploy%'`
|
|
1569
|
+
)?.count ?? 0;
|
|
1570
|
+
const deployScore = deployMentions > 0 || deployFiles > 0 ? 10 : 0;
|
|
1571
|
+
closeDb();
|
|
1572
|
+
const totalScore = featureScore + testScore + decisionScore + claimScore + deployScore;
|
|
1573
|
+
console.log("");
|
|
1574
|
+
console.log(chalk10.bold(` ${projectName} \u2014 Health Score: ${totalScore}/100
|
|
1575
|
+
`));
|
|
1576
|
+
const featureColor = featurePct >= 0.7 ? chalk10.green : featurePct >= 0.4 ? chalk10.yellow : chalk10.red;
|
|
1577
|
+
const featureMark = featurePct >= 0.4 ? "\u2705" : "\u26A0\uFE0F ";
|
|
1578
|
+
console.log(
|
|
1579
|
+
` ${featureMark} Features ${String(counts.done).padStart(2)}/${total} complete` + featureColor(` (${Math.round(featurePct * 100)}%)`) + chalk10.gray(` +${featureScore}pts`)
|
|
1580
|
+
);
|
|
1581
|
+
const testMark = testFiles > 0 ? "\u2705" : "\u26A0\uFE0F ";
|
|
1582
|
+
const testColor = testFiles > 0 ? chalk10.green : chalk10.red;
|
|
1583
|
+
console.log(
|
|
1584
|
+
` ${testMark} Tests ${testColor(String(testFiles) + " test files")}` + (testFiles === 0 ? chalk10.red(" (-20pts)") : chalk10.gray(` +${testScore}pts`))
|
|
1585
|
+
);
|
|
1586
|
+
const decMark = decisionCount > 0 ? "\u2705" : "\u26A0\uFE0F ";
|
|
1587
|
+
const decColor = decisionCount > 0 ? chalk10.green : chalk10.yellow;
|
|
1588
|
+
console.log(
|
|
1589
|
+
` ${decMark} Decisions ${decColor(decisionCount + " documented")}` + chalk10.gray(` +${decisionScore}pts`)
|
|
1590
|
+
);
|
|
1591
|
+
const claimMark = staleClaims === 0 ? "\u2705" : "\u26A0\uFE0F ";
|
|
1592
|
+
const claimColor = staleClaims === 0 ? chalk10.green : chalk10.red;
|
|
1593
|
+
console.log(
|
|
1594
|
+
` ${claimMark} Claims ${claimColor(staleClaims > 0 ? staleClaims + " stale (>24h)" : "0 stale")}` + chalk10.gray(` +${claimScore}pts`)
|
|
1595
|
+
);
|
|
1596
|
+
const deployMark = deployScore > 0 ? "\u2705" : "\u26A0\uFE0F ";
|
|
1597
|
+
const deployLabel = deployScore > 0 ? chalk10.green("detected") : chalk10.gray("not detected");
|
|
1598
|
+
console.log(
|
|
1599
|
+
` ${deployMark} Deploy ${deployLabel}` + (deployScore > 0 ? chalk10.gray(` +${deployScore}pts`) : chalk10.gray(" +0pts"))
|
|
1600
|
+
);
|
|
1601
|
+
console.log("");
|
|
1602
|
+
const recommendations = [];
|
|
1603
|
+
if (testFiles === 0) recommendations.push("Write tests before your next feature (0 test files found).");
|
|
1604
|
+
if (staleClaims > 0) recommendations.push(`Release ${staleClaims} stale claim(s) with groundctl complete <feature>.`);
|
|
1605
|
+
if (decisionCount === 0) recommendations.push("Document decisions during sessions so agents have context.");
|
|
1606
|
+
if (featurePct < 0.5 && total > 0) recommendations.push(`${counts.pending} features pending \u2014 run groundctl next to pick one.`);
|
|
1607
|
+
if (recommendations.length > 0) {
|
|
1608
|
+
console.log(chalk10.bold(" Recommendations:"));
|
|
1609
|
+
for (const r of recommendations) {
|
|
1610
|
+
console.log(chalk10.yellow(` \u2192 ${r}`));
|
|
1611
|
+
}
|
|
1612
|
+
console.log("");
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/index.ts
|
|
1617
|
+
var program = new Command();
|
|
1618
|
+
program.name("groundctl").description("The shared memory your agents and you actually need.").version("0.1.0");
|
|
1619
|
+
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 }));
|
|
1620
|
+
program.command("status").description("Show macro view of the product state").action(statusCommand);
|
|
1621
|
+
program.command("claim <feature>").description("Reserve a feature for the current session").option("-s, --session <id>", "Session ID (auto-generated if omitted)").action(claimCommand);
|
|
1622
|
+
program.command("complete <feature>").description("Mark a feature as done and release the claim").action(completeCommand);
|
|
1623
|
+
program.command("sync").description("Regenerate PROJECT_STATE.md and AGENTS.md from SQLite").action(syncCommand);
|
|
1624
|
+
program.command("next").description("Show next available (unclaimed) feature").action(nextCommand);
|
|
1625
|
+
program.command("log").description("Show session timeline").option("-s, --session <id>", "Show details for a specific session").action(logCommand);
|
|
1626
|
+
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").action(addCommand);
|
|
1627
|
+
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(
|
|
1628
|
+
(opts) => ingestCommand({
|
|
1629
|
+
source: opts.source,
|
|
1630
|
+
sessionId: opts.sessionId,
|
|
1631
|
+
transcript: opts.transcript,
|
|
1632
|
+
projectPath: opts.projectPath,
|
|
1633
|
+
noSync: !opts.sync
|
|
1634
|
+
})
|
|
1635
|
+
);
|
|
1636
|
+
program.command("report").description("Generate SESSION_REPORT.md from SQLite").option("-s, --session <id>", "Report for a specific session").option("--all", "Generate report for all sessions").action(reportCommand);
|
|
1637
|
+
program.command("health").description("Show product health score").action(healthCommand);
|
|
1638
|
+
program.parse();
|