@assistkick/create 1.2.0 → 1.4.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/package.json +2 -1
- package/templates/assistkick-product-system/GITHUB_APP_SETUP.md +88 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +231 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +49 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +82 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +19 -6
- package/templates/assistkick-product-system/packages/backend/src/services/github_app_service.ts +146 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +69 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +71 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +87 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +194 -0
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -17
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +114 -39
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +28 -14
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +151 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +352 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +208 -95
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +17 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +238 -105
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +15 -13
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useProjects.ts +4 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/dashboard.tsx +22 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +486 -38
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +4 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0003_lonely_cyclops.sql +17 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +826 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0002_snapshot.json +854 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0003_snapshot.json +862 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +21 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +10 -3
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +54 -1
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +25 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline-state-store.ts +4 -0
- package/templates/assistkick-product-system/packages/shared/lib/pipeline.ts +329 -89
- package/templates/assistkick-product-system/packages/shared/lib/pipeline_orchestrator.ts +186 -0
- package/templates/assistkick-product-system/packages/shared/lib/session.ts +10 -6
- package/templates/assistkick-product-system/packages/shared/tools/db_explorer.ts +275 -0
- package/templates/assistkick-product-system/packages/shared/tools/end_session.ts +2 -2
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/move_card.ts +3 -2
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/tests/kanban.test.ts +1 -1
- package/templates/assistkick-product-system/tests/pipeline_stats_all_cards.test.ts +1 -1
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +189 -150
- package/templates/skills/assistkick-bootstrap/SKILL.md +33 -25
- package/templates/skills/assistkick-code-reviewer/SKILL.md +23 -15
- package/templates/skills/assistkick-db-explorer/SKILL.md +86 -0
- package/templates/skills/assistkick-debugger/SKILL.md +30 -22
- package/templates/skills/assistkick-developer/SKILL.md +37 -29
- package/templates/skills/assistkick-interview/SKILL.md +34 -26
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PipelineOrchestrator — runs the Play All loop server-side.
|
|
3
|
+
* Iterates through TODO features sequentially, respecting dependency ordering.
|
|
4
|
+
* Continues across browser sessions. On server restart, does NOT auto-resume.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class PipelineOrchestrator {
|
|
8
|
+
private pipeline: any;
|
|
9
|
+
private loadKanban: (projectId?: string) => Promise<any>;
|
|
10
|
+
private readGraph: (projectId?: string) => Promise<any>;
|
|
11
|
+
private log: (tag: string, ...args: any[]) => void;
|
|
12
|
+
|
|
13
|
+
private active = false;
|
|
14
|
+
private aborted = false;
|
|
15
|
+
private currentFeatureId: string | null = null;
|
|
16
|
+
private projectId: string | null = null;
|
|
17
|
+
private processedCount = 0;
|
|
18
|
+
private skippedFeatures: string[] = [];
|
|
19
|
+
|
|
20
|
+
constructor({ pipeline, loadKanban, readGraph, log }: {
|
|
21
|
+
pipeline: any;
|
|
22
|
+
loadKanban: (projectId?: string) => Promise<any>;
|
|
23
|
+
readGraph: (projectId?: string) => Promise<any>;
|
|
24
|
+
log: (tag: string, ...args: any[]) => void;
|
|
25
|
+
}) {
|
|
26
|
+
this.pipeline = pipeline;
|
|
27
|
+
this.loadKanban = loadKanban;
|
|
28
|
+
this.readGraph = readGraph;
|
|
29
|
+
this.log = log;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
startPlayAll = async (projectId?: string) => {
|
|
33
|
+
if (this.active) {
|
|
34
|
+
return { error: 'Play All is already running', status: 409 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.active = true;
|
|
38
|
+
this.aborted = false;
|
|
39
|
+
this.currentFeatureId = null;
|
|
40
|
+
this.projectId = projectId || null;
|
|
41
|
+
this.processedCount = 0;
|
|
42
|
+
this.skippedFeatures = [];
|
|
43
|
+
|
|
44
|
+
this.log('ORCHESTRATOR', `Play All started${projectId ? ` for project ${projectId}` : ''}`);
|
|
45
|
+
|
|
46
|
+
// Fire-and-forget — loop runs in the background
|
|
47
|
+
this.runLoop().catch(err => {
|
|
48
|
+
this.log('ORCHESTRATOR', `Play All UNCAUGHT ERROR: ${err.message}`);
|
|
49
|
+
}).finally(() => {
|
|
50
|
+
this.active = false;
|
|
51
|
+
this.currentFeatureId = null;
|
|
52
|
+
this.log('ORCHESTRATOR', `Play All ended. Processed ${this.processedCount} features.`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { started: true, status: 200 };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
stopPlayAll = () => {
|
|
59
|
+
if (!this.active) {
|
|
60
|
+
return { error: 'Play All is not running', status: 400 };
|
|
61
|
+
}
|
|
62
|
+
this.aborted = true;
|
|
63
|
+
this.log('ORCHESTRATOR', 'Play All stop requested');
|
|
64
|
+
return { stopped: true, status: 200 };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
getStatus = () => {
|
|
68
|
+
return {
|
|
69
|
+
active: this.active,
|
|
70
|
+
currentFeatureId: this.currentFeatureId,
|
|
71
|
+
projectId: this.projectId,
|
|
72
|
+
processedCount: this.processedCount,
|
|
73
|
+
skippedFeatures: this.skippedFeatures,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
private runLoop = async () => {
|
|
78
|
+
while (!this.aborted) {
|
|
79
|
+
// Fetch fresh kanban and graph data each iteration
|
|
80
|
+
let kanbanData: any;
|
|
81
|
+
let graphData: any;
|
|
82
|
+
try {
|
|
83
|
+
kanbanData = await this.loadKanban(this.projectId || undefined);
|
|
84
|
+
graphData = await this.readGraph(this.projectId || undefined);
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
this.log('ORCHESTRATOR', `Failed to fetch data: ${err.message}`);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build feature node lookup
|
|
91
|
+
const featureNodes = new Map<string, any>();
|
|
92
|
+
graphData.nodes
|
|
93
|
+
.filter((n: any) => n.type === 'feature')
|
|
94
|
+
.forEach((n: any) => featureNodes.set(n.id, n));
|
|
95
|
+
|
|
96
|
+
// Get TODO cards that aren't dev_blocked, sorted by completeness (highest first)
|
|
97
|
+
const todoCards = Object.entries(kanbanData)
|
|
98
|
+
.filter(([id, entry]: [string, any]) =>
|
|
99
|
+
entry.column === 'todo' && featureNodes.has(id) && !entry.dev_blocked
|
|
100
|
+
)
|
|
101
|
+
.map(([id]: [string, any]) => ({
|
|
102
|
+
id,
|
|
103
|
+
completeness: featureNodes.get(id).completeness || 0,
|
|
104
|
+
}))
|
|
105
|
+
.sort((a, b) => b.completeness - a.completeness);
|
|
106
|
+
|
|
107
|
+
if (todoCards.length === 0) {
|
|
108
|
+
this.log('ORCHESTRATOR', 'No TODO cards remaining — stopping');
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Try to find an unblocked card
|
|
113
|
+
let processed = false;
|
|
114
|
+
this.skippedFeatures = [];
|
|
115
|
+
|
|
116
|
+
for (const card of todoCards) {
|
|
117
|
+
if (this.aborted) return;
|
|
118
|
+
|
|
119
|
+
// Check dependency ordering
|
|
120
|
+
const deps = graphData.edges
|
|
121
|
+
.filter((e: any) => e.from === card.id && e.relation === 'depends_on')
|
|
122
|
+
.map((e: any) => e.to)
|
|
123
|
+
.filter((depId: string) => graphData.nodes.some((n: any) => n.id === depId && n.type === 'feature'));
|
|
124
|
+
const blocked = deps.some((depId: string) =>
|
|
125
|
+
!kanbanData[depId] || kanbanData[depId].column !== 'done'
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (blocked) {
|
|
129
|
+
this.skippedFeatures.push(card.id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Start pipeline for this card
|
|
134
|
+
this.currentFeatureId = card.id;
|
|
135
|
+
this.log('ORCHESTRATOR', `Starting pipeline for ${card.id}`);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await this.pipeline.start(card.id);
|
|
139
|
+
if (result.error) {
|
|
140
|
+
this.log('ORCHESTRATOR', `Failed to start pipeline for ${card.id}: ${result.error}`);
|
|
141
|
+
// Try the next card if this one failed to start
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
this.log('ORCHESTRATOR', `Failed to start pipeline for ${card.id}: ${err.message}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wait for pipeline to reach a terminal status
|
|
150
|
+
await this.waitForPipelineCompletion(card.id);
|
|
151
|
+
|
|
152
|
+
if (this.aborted) return;
|
|
153
|
+
|
|
154
|
+
this.processedCount++;
|
|
155
|
+
processed = true;
|
|
156
|
+
break; // Process one card per iteration, then re-fetch data
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Deadlock: a full pass with zero features processable
|
|
160
|
+
if (!processed) {
|
|
161
|
+
this.log('ORCHESTRATOR', `Deadlock detected — ${this.skippedFeatures.length} features blocked: ${this.skippedFeatures.join(', ')}`);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
private waitForPipelineCompletion = async (featureId: string): Promise<void> => {
|
|
168
|
+
const TERMINAL_STATUSES = ['idle', 'completed', 'blocked', 'failed', 'interrupted'];
|
|
169
|
+
const POLL_INTERVAL_MS = 5000;
|
|
170
|
+
|
|
171
|
+
while (!this.aborted) {
|
|
172
|
+
try {
|
|
173
|
+
const status = await this.pipeline.getStatus(featureId);
|
|
174
|
+
if (TERMINAL_STATUSES.includes(status.status)) {
|
|
175
|
+
this.log('ORCHESTRATOR', `Pipeline for ${featureId} reached terminal status: ${status.status}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
this.log('ORCHESTRATOR', `Error polling status for ${featureId}: ${err.message}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -22,7 +22,7 @@ export const getNextSessionNumber = async (projectId?: string) => {
|
|
|
22
22
|
/**
|
|
23
23
|
* Create a new session record.
|
|
24
24
|
*/
|
|
25
|
-
export const createSession = async (sessionNum, body, projectId
|
|
25
|
+
export const createSession = async (sessionNum, body, projectId: string) => {
|
|
26
26
|
const db = getDb();
|
|
27
27
|
const now = new Date().toISOString();
|
|
28
28
|
|
|
@@ -34,7 +34,7 @@ export const createSession = async (sessionNum, body, projectId?: string) => {
|
|
|
34
34
|
nodesTouched: '[]',
|
|
35
35
|
questionsResolved: 0,
|
|
36
36
|
body: body || '',
|
|
37
|
-
projectId
|
|
37
|
+
projectId,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
return { session: sessionNum, started_at: now };
|
|
@@ -55,7 +55,7 @@ export const getLatestSession = async (projectId?: string) => {
|
|
|
55
55
|
/**
|
|
56
56
|
* Update/end a session with summary and metadata.
|
|
57
57
|
*/
|
|
58
|
-
export const endSession = async (sessionNum, updates) => {
|
|
58
|
+
export const endSession = async (sessionNum, updates, projectId: string) => {
|
|
59
59
|
const db = getDb();
|
|
60
60
|
|
|
61
61
|
const updateFields = {};
|
|
@@ -65,15 +65,19 @@ export const endSession = async (sessionNum, updates) => {
|
|
|
65
65
|
if (updates.questions_resolved !== undefined) updateFields.questionsResolved = updates.questions_resolved;
|
|
66
66
|
if (updates.body !== undefined) updateFields.body = updates.body;
|
|
67
67
|
|
|
68
|
-
await db.update(sessions).set(updateFields).where(
|
|
68
|
+
await db.update(sessions).set(updateFields).where(
|
|
69
|
+
and(eq(sessions.sessionNumber, sessionNum), eq(sessions.projectId, projectId))
|
|
70
|
+
);
|
|
69
71
|
};
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* Get a session by number.
|
|
73
75
|
*/
|
|
74
|
-
export const getSession = async (sessionNum) => {
|
|
76
|
+
export const getSession = async (sessionNum, projectId: string) => {
|
|
75
77
|
const db = getDb();
|
|
76
|
-
const rows = await db.select().from(sessions).where(
|
|
78
|
+
const rows = await db.select().from(sessions).where(
|
|
79
|
+
and(eq(sessions.sessionNumber, sessionNum), eq(sessions.projectId, projectId))
|
|
80
|
+
);
|
|
77
81
|
return rows[0];
|
|
78
82
|
};
|
|
79
83
|
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* db_explorer — Read-only database explorer for AI assistants.
|
|
5
|
+
* Provides safe, read-only access to inspect database tables, schemas, and data.
|
|
6
|
+
* Absolutely NO write capabilities — only SELECT, PRAGMA, and EXPLAIN queries allowed.
|
|
7
|
+
* Sensitive columns (password_hash, token_hash) are automatically redacted.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx tsx tools/db_explorer.ts tables
|
|
11
|
+
* npx tsx tools/db_explorer.ts describe <table>
|
|
12
|
+
* npx tsx tools/db_explorer.ts count <table> [--where "..."]
|
|
13
|
+
* npx tsx tools/db_explorer.ts sample <table> [--limit N] [--where "..."] [--order-by <col>]
|
|
14
|
+
* npx tsx tools/db_explorer.ts query "SELECT ..."
|
|
15
|
+
*
|
|
16
|
+
* Note: --project-id is optional. Use --where "project_id = '...'" to filter by project.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { program } from 'commander';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { createClient } from '@libsql/client';
|
|
22
|
+
import { config } from 'dotenv';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
let PROJECT_ROOT = join(__dirname, '..');
|
|
29
|
+
while (PROJECT_ROOT !== dirname(PROJECT_ROOT)) {
|
|
30
|
+
if (existsSync(join(PROJECT_ROOT, 'pnpm-workspace.yaml'))) break;
|
|
31
|
+
PROJECT_ROOT = dirname(PROJECT_ROOT);
|
|
32
|
+
}
|
|
33
|
+
const ENV_PATH = join(PROJECT_ROOT, '.env');
|
|
34
|
+
|
|
35
|
+
const SENSITIVE_COLUMNS = ['password_hash', 'token_hash'];
|
|
36
|
+
|
|
37
|
+
function getClient() {
|
|
38
|
+
if (existsSync(ENV_PATH)) {
|
|
39
|
+
config({ path: ENV_PATH, quiet: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tursoUrl = process.env.TURSO_DATABASE_URL;
|
|
43
|
+
const tursoAuthToken = process.env.TURSO_AUTH_TOKEN;
|
|
44
|
+
|
|
45
|
+
if (tursoUrl && tursoAuthToken) {
|
|
46
|
+
return createClient({ url: tursoUrl, authToken: tursoAuthToken });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dataDir = join(PROJECT_ROOT, 'data');
|
|
50
|
+
return createClient({ url: `file:${join(dataDir, 'local.db')}` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates that a SQL query is strictly read-only.
|
|
55
|
+
* Returns true only for SELECT, PRAGMA, and EXPLAIN queries.
|
|
56
|
+
*/
|
|
57
|
+
function isReadOnly(query: string): boolean {
|
|
58
|
+
// Strip SQL comments
|
|
59
|
+
let cleaned = query
|
|
60
|
+
.replace(/--[^\n]*/g, '')
|
|
61
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
62
|
+
.trim();
|
|
63
|
+
|
|
64
|
+
// Remove trailing semicolon
|
|
65
|
+
cleaned = cleaned.replace(/;\s*$/, '');
|
|
66
|
+
|
|
67
|
+
// Strip string literals to avoid false positives
|
|
68
|
+
const noStrings = cleaned.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
69
|
+
|
|
70
|
+
// No multiple statements
|
|
71
|
+
if (noStrings.includes(';')) return false;
|
|
72
|
+
|
|
73
|
+
const upper = noStrings.toUpperCase().replace(/\s+/g, ' ');
|
|
74
|
+
|
|
75
|
+
// Must start with SELECT, PRAGMA, or EXPLAIN
|
|
76
|
+
if (!/^(SELECT|PRAGMA|EXPLAIN)\b/.test(upper)) return false;
|
|
77
|
+
|
|
78
|
+
// Block write/DDL/dangerous keywords
|
|
79
|
+
if (/\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|ATTACH|DETACH|VACUUM|REINDEX|GRANT|REVOKE|LOAD_EXTENSION)\b/.test(upper)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function redactRows(rows: Record<string, unknown>[], columns: string[]): Record<string, unknown>[] {
|
|
87
|
+
const hasSensitive = columns.some(c => SENSITIVE_COLUMNS.includes(c.toLowerCase()));
|
|
88
|
+
if (!hasSensitive) return rows;
|
|
89
|
+
|
|
90
|
+
return rows.map(row => {
|
|
91
|
+
const copy = { ...row };
|
|
92
|
+
for (const col of SENSITIVE_COLUMNS) {
|
|
93
|
+
if (col in copy) copy[col] = '[REDACTED]';
|
|
94
|
+
}
|
|
95
|
+
return copy;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function printRows(rows: Record<string, unknown>[], columns: string[]) {
|
|
100
|
+
if (rows.length === 0) {
|
|
101
|
+
console.log(chalk.yellow(' (no rows)'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const row of rows) {
|
|
105
|
+
console.log(chalk.gray(' ─────────'));
|
|
106
|
+
for (const col of columns) {
|
|
107
|
+
const val = row[col];
|
|
108
|
+
const display = val === null
|
|
109
|
+
? chalk.gray('NULL')
|
|
110
|
+
: typeof val === 'string' && val.length > 200
|
|
111
|
+
? val.substring(0, 200) + '...'
|
|
112
|
+
: String(val);
|
|
113
|
+
console.log(` ${chalk.bold(col)}: ${display}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sanitizeTableName(name: string): string {
|
|
119
|
+
return name.replace(/[^a-zA-Z0-9_]/g, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
program
|
|
123
|
+
.argument('<action>', 'Action: tables, describe, count, sample, query')
|
|
124
|
+
.argument('[target]', 'Table name or SQL query (for query action)')
|
|
125
|
+
.option('--project-id <id>', 'Project ID (optional — use --where to filter by project)')
|
|
126
|
+
.option('--limit <n>', 'Row limit for sample action', '10')
|
|
127
|
+
.option('--where <condition>', 'WHERE clause for count/sample')
|
|
128
|
+
.option('--order-by <column>', 'ORDER BY clause for sample')
|
|
129
|
+
.parse();
|
|
130
|
+
|
|
131
|
+
const [action, target] = program.args;
|
|
132
|
+
const opts = program.opts();
|
|
133
|
+
|
|
134
|
+
(async () => {
|
|
135
|
+
const client = getClient();
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
switch (action) {
|
|
139
|
+
case 'tables': {
|
|
140
|
+
const result = await client.execute(
|
|
141
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle_%' AND name NOT LIKE '_litestream_%' ORDER BY name"
|
|
142
|
+
);
|
|
143
|
+
const tableNames = result.rows.map(r => r.name as string);
|
|
144
|
+
|
|
145
|
+
console.log(chalk.cyan.bold('Database Tables:\n'));
|
|
146
|
+
for (const t of tableNames) {
|
|
147
|
+
const countResult = await client.execute(`SELECT COUNT(*) as cnt FROM "${t}"`);
|
|
148
|
+
const cnt = countResult.rows[0]?.cnt ?? 0;
|
|
149
|
+
console.log(` ${chalk.bold(t)} (${cnt} rows)`);
|
|
150
|
+
}
|
|
151
|
+
console.log('\n' + JSON.stringify({ tables: tableNames }));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'describe': {
|
|
156
|
+
if (!target) throw new Error('Usage: db_explorer describe <table_name>');
|
|
157
|
+
const table = sanitizeTableName(target);
|
|
158
|
+
|
|
159
|
+
const result = await client.execute(`PRAGMA table_info("${table}")`);
|
|
160
|
+
if (result.rows.length === 0) throw new Error(`Table not found: ${target}`);
|
|
161
|
+
|
|
162
|
+
console.log(chalk.cyan.bold(`\nTable: ${table}\n`));
|
|
163
|
+
console.log(chalk.gray(' # Name Type Nullable Default PK'));
|
|
164
|
+
console.log(chalk.gray(' ' + '─'.repeat(75)));
|
|
165
|
+
|
|
166
|
+
for (const col of result.rows) {
|
|
167
|
+
const nullable = col.notnull ? 'NOT NULL' : 'NULL ';
|
|
168
|
+
const pk = col.pk ? chalk.yellow('PK') : ' ';
|
|
169
|
+
const def = col.dflt_value !== null ? String(col.dflt_value) : '';
|
|
170
|
+
console.log(` ${String(col.cid).padEnd(3)} ${chalk.bold(String(col.name).padEnd(25))} ${String(col.type).padEnd(12)} ${nullable} ${def.padEnd(14)} ${pk}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const idxResult = await client.execute(`PRAGMA index_list("${table}")`);
|
|
174
|
+
if (idxResult.rows.length > 0) {
|
|
175
|
+
console.log(chalk.cyan.bold('\n Indexes:'));
|
|
176
|
+
for (const idx of idxResult.rows) {
|
|
177
|
+
const unique = idx.unique ? chalk.yellow(' UNIQUE') : '';
|
|
178
|
+
const idxName = sanitizeTableName(String(idx.name));
|
|
179
|
+
const idxInfoResult = await client.execute(`PRAGMA index_info("${idxName}")`);
|
|
180
|
+
const cols = idxInfoResult.rows.map(r => r.name).join(', ');
|
|
181
|
+
console.log(` ${idx.name}${unique} (${cols})`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fkResult = await client.execute(`PRAGMA foreign_key_list("${table}")`);
|
|
186
|
+
if (fkResult.rows.length > 0) {
|
|
187
|
+
console.log(chalk.cyan.bold('\n Foreign Keys:'));
|
|
188
|
+
for (const fk of fkResult.rows) {
|
|
189
|
+
console.log(` ${fk.from} -> ${fk.table}.${fk.to}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log('\n' + JSON.stringify({
|
|
194
|
+
table,
|
|
195
|
+
columns: result.rows.map(c => ({
|
|
196
|
+
name: c.name, type: c.type, nullable: !c.notnull,
|
|
197
|
+
default: c.dflt_value, pk: !!c.pk,
|
|
198
|
+
})),
|
|
199
|
+
}));
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'count': {
|
|
204
|
+
if (!target) throw new Error('Usage: db_explorer count <table_name>');
|
|
205
|
+
const table = sanitizeTableName(target);
|
|
206
|
+
let query = `SELECT COUNT(*) as cnt FROM "${table}"`;
|
|
207
|
+
if (opts.where) query += ` WHERE ${opts.where}`;
|
|
208
|
+
|
|
209
|
+
if (!isReadOnly(query)) throw new Error('Query validation failed — only read-only queries are allowed');
|
|
210
|
+
|
|
211
|
+
const result = await client.execute(query);
|
|
212
|
+
const count = result.rows[0]?.cnt ?? 0;
|
|
213
|
+
console.log(chalk.cyan(`Row count for ${chalk.bold(table)}: ${chalk.green(String(count))}`));
|
|
214
|
+
console.log(JSON.stringify({ table, count }));
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case 'sample': {
|
|
219
|
+
if (!target) throw new Error('Usage: db_explorer sample <table_name> [--limit N] [--where "..."]');
|
|
220
|
+
const table = sanitizeTableName(target);
|
|
221
|
+
const limit = parseInt(opts.limit) || 10;
|
|
222
|
+
|
|
223
|
+
let query = `SELECT * FROM "${table}"`;
|
|
224
|
+
if (opts.where) query += ` WHERE ${opts.where}`;
|
|
225
|
+
if (opts.orderBy) query += ` ORDER BY ${opts.orderBy}`;
|
|
226
|
+
query += ` LIMIT ${limit}`;
|
|
227
|
+
|
|
228
|
+
if (!isReadOnly(query)) throw new Error('Query validation failed — only read-only queries are allowed');
|
|
229
|
+
|
|
230
|
+
const result = await client.execute(query);
|
|
231
|
+
const columns = result.columns.length > 0
|
|
232
|
+
? result.columns
|
|
233
|
+
: result.rows.length > 0 ? Object.keys(result.rows[0]) : [];
|
|
234
|
+
const rows = redactRows(result.rows as Record<string, unknown>[], columns);
|
|
235
|
+
|
|
236
|
+
console.log(chalk.cyan.bold(`\nSample from ${table} (${rows.length} rows):\n`));
|
|
237
|
+
printRows(rows, columns);
|
|
238
|
+
console.log('\n' + JSON.stringify({ table, count: rows.length, columns, rows }));
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'query': {
|
|
243
|
+
if (!target) throw new Error('Usage: db_explorer query "SELECT ..."');
|
|
244
|
+
if (!isReadOnly(target)) {
|
|
245
|
+
throw new Error('Only read-only queries are allowed (SELECT, PRAGMA, EXPLAIN). Write operations are blocked.');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Add safety limit if none specified
|
|
249
|
+
const upper = target.toUpperCase();
|
|
250
|
+
const query = upper.startsWith('SELECT') && !upper.includes('LIMIT')
|
|
251
|
+
? `${target} LIMIT 100`
|
|
252
|
+
: target;
|
|
253
|
+
|
|
254
|
+
const result = await client.execute(query);
|
|
255
|
+
const columns = result.columns.length > 0
|
|
256
|
+
? result.columns
|
|
257
|
+
: result.rows.length > 0 ? Object.keys(result.rows[0]) : [];
|
|
258
|
+
const rows = redactRows(result.rows as Record<string, unknown>[], columns);
|
|
259
|
+
|
|
260
|
+
console.log(chalk.cyan(`\nQuery returned ${rows.length} row(s):\n`));
|
|
261
|
+
printRows(rows, columns);
|
|
262
|
+
console.log('\n' + JSON.stringify({ count: rows.length, columns, rows }));
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
default:
|
|
267
|
+
throw new Error(`Unknown action "${action}". Valid actions: tables, describe, count, sample, query`);
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
271
|
+
process.exit(1);
|
|
272
|
+
} finally {
|
|
273
|
+
client.close();
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
@@ -36,7 +36,7 @@ const opts = program.opts();
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Add gap analysis
|
|
39
|
-
const graph = await readGraph();
|
|
39
|
+
const graph = await readGraph(opts.projectId);
|
|
40
40
|
const graphNodes = graph.nodes;
|
|
41
41
|
const totalNodes = graphNodes.length;
|
|
42
42
|
const avgCompleteness = totalNodes > 0
|
|
@@ -74,7 +74,7 @@ const opts = program.opts();
|
|
|
74
74
|
nodes_touched: opts.nodesTouched ? opts.nodesTouched.split(',').map(s => s.trim()) : [],
|
|
75
75
|
questions_resolved: opts.questionsResolved,
|
|
76
76
|
body: updatedBody,
|
|
77
|
-
});
|
|
77
|
+
}, opts.projectId);
|
|
78
78
|
|
|
79
79
|
console.log(chalk.green(`✓ Session ${session.sessionNumber} ended`));
|
|
80
80
|
console.log(`Nodes: ${totalNodes} — ${Math.round(avgCompleteness * 100)}% complete`);
|
|
@@ -14,9 +14,10 @@ import chalk from 'chalk';
|
|
|
14
14
|
import { readGraph } from '../lib/graph.js';
|
|
15
15
|
import { loadKanban, kanbanExists } from '../lib/kanban.js';
|
|
16
16
|
|
|
17
|
-
const VALID_COLUMNS = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
17
|
+
const VALID_COLUMNS = ['backlog', 'todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
18
18
|
|
|
19
19
|
const COLUMN_COLORS = {
|
|
20
|
+
backlog: chalk.dim,
|
|
20
21
|
todo: chalk.gray,
|
|
21
22
|
in_progress: chalk.blue,
|
|
22
23
|
in_review: chalk.magenta,
|
|
@@ -25,11 +25,12 @@ import { program } from 'commander';
|
|
|
25
25
|
import chalk from 'chalk';
|
|
26
26
|
import { getKanbanEntry, saveKanbanEntry, kanbanExists } from '../lib/kanban.js';
|
|
27
27
|
|
|
28
|
-
const VALID_COLUMNS = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
28
|
+
const VALID_COLUMNS = ['backlog', 'todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
29
29
|
|
|
30
30
|
// AI-allowed transitions (via CLI)
|
|
31
31
|
const ALLOWED_TRANSITIONS = {
|
|
32
|
-
'
|
|
32
|
+
'backlog': ['todo'],
|
|
33
|
+
'todo': ['in_progress', 'backlog'],
|
|
33
34
|
'in_progress': ['in_review'],
|
|
34
35
|
'in_review': ['qa', 'todo'],
|
|
35
36
|
'qa': ['todo'],
|
|
@@ -114,8 +114,8 @@ const opts = program.opts();
|
|
|
114
114
|
if (nodeMeta.type === 'feature' && finalStatus === 'defined' && meta.completeness >= 1) {
|
|
115
115
|
const existing = await getKanbanEntry(id);
|
|
116
116
|
if (!existing) {
|
|
117
|
-
await saveKanbanEntry(id, { column: '
|
|
118
|
-
console.log(chalk.green(`✓ Auto-added ${id} to kanban board (
|
|
117
|
+
await saveKanbanEntry(id, { column: 'backlog', rejection_count: 0, notes: [] }, opts.projectId);
|
|
118
|
+
console.log(chalk.green(`✓ Auto-added ${id} to kanban board (backlog)`));
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
12
12
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
14
14
|
// Valid kanban columns — users can move cards between any columns (mirrors server logic)
|
|
15
|
-
const VALID_COLUMNS = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
15
|
+
const VALID_COLUMNS = ['backlog', 'todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
16
16
|
|
|
17
17
|
// In-memory kanban data used by the test server
|
|
18
18
|
let kanbanData;
|
|
@@ -96,7 +96,7 @@ describe('Pipeline stats on all kanban cards', () => {
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
describe('shouldShowStats — stats visibility for all columns', () => {
|
|
99
|
-
const columns = ['todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
99
|
+
const columns = ['backlog', 'todo', 'in_progress', 'in_review', 'qa', 'done'];
|
|
100
100
|
|
|
101
101
|
for (const column of columns) {
|
|
102
102
|
it(`shows stats for ${column} card with completed pipeline`, () => {
|