create-claude-cabinet 0.8.5 → 0.9.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/lib/cli.js +154 -10
- package/lib/copy.js +13 -0
- package/lib/omega-setup.js +4 -3
- package/package.json +1 -1
- package/templates/scripts/work-tracker-server.mjs +188 -0
- package/templates/scripts/work-tracker-ui.html +948 -0
- package/templates/skills/cc-upgrade/SKILL.md +68 -0
- package/templates/skills/debrief/phases/close-work.md +33 -0
- package/templates/skills/execute-plans/SKILL.md +273 -0
- package/templates/skills/execute-plans/scripts/build-conflict-graph.js +281 -0
- package/templates/skills/orient/SKILL.md +59 -4
- package/templates/skills/plan/SKILL.md +11 -2
- package/templates/scripts/__pycache__/cabinet-memory-adapter.cpython-314.pyc +0 -0
package/lib/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ const MODULES = {
|
|
|
33
33
|
mandatory: false,
|
|
34
34
|
default: true,
|
|
35
35
|
lean: false,
|
|
36
|
-
templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql'],
|
|
36
|
+
templates: ['scripts/pib-db.js', 'scripts/pib-db-schema.sql', 'scripts/work-tracker-server.mjs', 'scripts/work-tracker-ui.html'],
|
|
37
37
|
needsDb: true,
|
|
38
38
|
},
|
|
39
39
|
'planning': {
|
|
@@ -42,7 +42,7 @@ const MODULES = {
|
|
|
42
42
|
mandatory: false,
|
|
43
43
|
default: true,
|
|
44
44
|
lean: true,
|
|
45
|
-
templates: ['skills/plan', 'skills/execute', 'skills/investigate'],
|
|
45
|
+
templates: ['skills/plan', 'skills/execute', 'skills/execute-plans', 'skills/investigate'],
|
|
46
46
|
},
|
|
47
47
|
'compliance': {
|
|
48
48
|
name: 'Compliance Stack (rules + enforcement)',
|
|
@@ -107,6 +107,22 @@ const MODULES = {
|
|
|
107
107
|
},
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/** Recursively collect all relative file paths under a directory. */
|
|
111
|
+
function walkDir(dir, base) {
|
|
112
|
+
if (!base) base = dir;
|
|
113
|
+
const results = [];
|
|
114
|
+
if (!fs.existsSync(dir)) return results;
|
|
115
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
const full = path.join(dir, entry.name);
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
results.push(...walkDir(full, base));
|
|
119
|
+
} else {
|
|
120
|
+
results.push(path.relative(base, full));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
// Signals that a directory contains a real project (not just empty)
|
|
111
127
|
const PROJECT_SIGNALS = [
|
|
112
128
|
'package.json', 'Cargo.toml', 'requirements.txt', 'pyproject.toml',
|
|
@@ -572,24 +588,152 @@ async function run() {
|
|
|
572
588
|
}
|
|
573
589
|
}
|
|
574
590
|
|
|
575
|
-
// ---
|
|
576
|
-
//
|
|
577
|
-
//
|
|
591
|
+
// --- Manifest key migration (act:d1f16bee) ---
|
|
592
|
+
// When CC renames directories (e.g., perspectives/ → cabinet-*/), old manifest
|
|
593
|
+
// keys no longer match new template paths. Migrate keys BEFORE cleanup so the
|
|
594
|
+
// cleanup loop doesn't treat renamed files as removed.
|
|
595
|
+
if (Object.keys(existingManifest).length > 0) {
|
|
596
|
+
const existing = readMetadata(projectDir);
|
|
597
|
+
const fromVersion = existing.version;
|
|
598
|
+
let migrationCount = 0;
|
|
599
|
+
// v0.5.x → v0.6.x: perspectives/ → cabinet-*/
|
|
600
|
+
if (fromVersion && /^0\.[0-5]\./.test(fromVersion)) {
|
|
601
|
+
for (const key of Object.keys(existingManifest)) {
|
|
602
|
+
const match = key.match(/\.claude\/skills\/perspectives\/([^/]+)\//);
|
|
603
|
+
if (match) {
|
|
604
|
+
const newKey = key.replace(`perspectives/${match[1]}/`, `cabinet-${match[1]}/`);
|
|
605
|
+
existingManifest[newKey] = existingManifest[key];
|
|
606
|
+
delete existingManifest[key];
|
|
607
|
+
migrationCount++;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Future manifest key migrations go here
|
|
612
|
+
if (migrationCount > 0) {
|
|
613
|
+
console.log(` 🔄 Migrated ${migrationCount} manifest key${migrationCount === 1 ? '' : 's'} for directory rename`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- Clean up files removed upstream (safeguarded) ---
|
|
618
|
+
// Four safeguards prevent the v0.6.8 incident from recurring:
|
|
619
|
+
// S1: Classify — only delete files that map to a known CC template path
|
|
620
|
+
// S2: Scope — only delete files from modules the user selected
|
|
621
|
+
// S3: Itemize — list all deletions and confirm before proceeding
|
|
622
|
+
// S4: Backup — copy files to .cc-backup/<timestamp>/ before deletion
|
|
578
623
|
if (Object.keys(existingManifest).length > 0) {
|
|
579
|
-
|
|
624
|
+
// S1: Build complete template path set (ALL modules) for classification
|
|
625
|
+
const allTemplatePaths = new Set();
|
|
626
|
+
for (const mod of Object.values(MODULES)) {
|
|
627
|
+
for (const tmpl of mod.templates || []) {
|
|
628
|
+
const srcPath = path.join(templateRoot, tmpl);
|
|
629
|
+
if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
|
|
630
|
+
for (const rel of walkDir(srcPath)) {
|
|
631
|
+
allTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
|
|
632
|
+
}
|
|
633
|
+
} else if (fs.existsSync(srcPath)) {
|
|
634
|
+
allTemplatePaths.add(manifestPath(tmpl));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// S2: Build selected-module template paths for scoping
|
|
640
|
+
const selectedTemplatePaths = new Set();
|
|
641
|
+
for (const modKey of selectedModules) {
|
|
642
|
+
const mod = MODULES[modKey];
|
|
643
|
+
for (const tmpl of mod.templates || []) {
|
|
644
|
+
const srcPath = path.join(templateRoot, tmpl);
|
|
645
|
+
if (fs.existsSync(srcPath) && fs.statSync(srcPath).isDirectory()) {
|
|
646
|
+
for (const rel of walkDir(srcPath)) {
|
|
647
|
+
selectedTemplatePaths.add(manifestPath(tmpl) + '/' + rel);
|
|
648
|
+
}
|
|
649
|
+
} else if (fs.existsSync(srcPath)) {
|
|
650
|
+
selectedTemplatePaths.add(manifestPath(tmpl));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Build CC template skill name set for project-skill guard (act:4ac281ba)
|
|
656
|
+
const ccSkillNames = new Set();
|
|
657
|
+
for (const mod of Object.values(MODULES)) {
|
|
658
|
+
for (const tmpl of mod.templates || []) {
|
|
659
|
+
if (tmpl.startsWith('skills/')) {
|
|
660
|
+
const skillName = tmpl.split('/')[1];
|
|
661
|
+
ccSkillNames.add(skillName);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Collect files that would be removed
|
|
667
|
+
const toRemove = [];
|
|
580
668
|
for (const oldPath of Object.keys(existingManifest)) {
|
|
581
669
|
if (!allManifest[oldPath]) {
|
|
582
670
|
// Skip phase files — they may be user-customized
|
|
583
671
|
if (/\/phases\//.test(oldPath)) continue;
|
|
672
|
+
|
|
673
|
+
// S1: Only delete if the path maps to a known CC template
|
|
674
|
+
if (!allTemplatePaths.has(oldPath)) {
|
|
675
|
+
console.log(` Keeping non-template file: ${oldPath}`);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// S2: Only delete from selected modules (don't purge deselected modules)
|
|
680
|
+
if (!selectedTemplatePaths.has(oldPath)) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Project-skill guard: never delete skills CC didn't create
|
|
685
|
+
const skillDirMatch = oldPath.match(/^\.claude\/skills\/([^/]+)\//);
|
|
686
|
+
if (skillDirMatch && !ccSkillNames.has(skillDirMatch[1])) {
|
|
687
|
+
console.log(` Keeping project skill: ${oldPath}`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
|
|
584
691
|
const fullPath = path.join(projectDir, oldPath);
|
|
585
692
|
if (fs.existsSync(fullPath)) {
|
|
586
|
-
|
|
587
|
-
totalRemoved++;
|
|
693
|
+
toRemove.push(oldPath);
|
|
588
694
|
}
|
|
589
695
|
}
|
|
590
696
|
}
|
|
591
|
-
|
|
592
|
-
|
|
697
|
+
|
|
698
|
+
// S3: Itemize and confirm before deleting
|
|
699
|
+
if (toRemove.length > 0) {
|
|
700
|
+
console.log(`\n Files no longer in upstream (${toRemove.length}):`);
|
|
701
|
+
for (const f of toRemove) {
|
|
702
|
+
console.log(` - ${f}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let confirmed = flags.yes;
|
|
706
|
+
if (!confirmed && !flags.dryRun) {
|
|
707
|
+
const response = await prompts({
|
|
708
|
+
type: 'confirm',
|
|
709
|
+
name: 'confirmed',
|
|
710
|
+
message: `Remove ${toRemove.length} file${toRemove.length === 1 ? '' : 's'}?`,
|
|
711
|
+
initial: false,
|
|
712
|
+
});
|
|
713
|
+
confirmed = response.confirmed;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (confirmed && !flags.dryRun) {
|
|
717
|
+
// S4: Backup before deleting
|
|
718
|
+
const backupDir = path.join(projectDir, '.cc-backup', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
719
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
720
|
+
for (const filePath of toRemove) {
|
|
721
|
+
const src = path.join(projectDir, filePath);
|
|
722
|
+
const dest = path.join(backupDir, filePath);
|
|
723
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
724
|
+
fs.copyFileSync(src, dest);
|
|
725
|
+
}
|
|
726
|
+
console.log(` 📦 Backed up ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} to ${path.relative(projectDir, backupDir)}/`);
|
|
727
|
+
|
|
728
|
+
for (const filePath of toRemove) {
|
|
729
|
+
fs.unlinkSync(path.join(projectDir, filePath));
|
|
730
|
+
}
|
|
731
|
+
console.log(` 🧹 Removed ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} no longer in upstream`);
|
|
732
|
+
} else if (flags.dryRun) {
|
|
733
|
+
console.log(` [dry run — ${toRemove.length} file${toRemove.length === 1 ? '' : 's'} would be removed]`);
|
|
734
|
+
} else {
|
|
735
|
+
console.log(' Skipped file removal.');
|
|
736
|
+
}
|
|
593
737
|
}
|
|
594
738
|
}
|
|
595
739
|
|
package/lib/copy.js
CHANGED
|
@@ -45,6 +45,19 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
|
|
|
45
45
|
if (fs.existsSync(destPath)) {
|
|
46
46
|
const existing = fs.readFileSync(destPath, 'utf8');
|
|
47
47
|
|
|
48
|
+
// Phase file guard (act:98d74381) — independent of skipPhases flag.
|
|
49
|
+
// If a phase file on disk has been customized (differs from template
|
|
50
|
+
// and isn't empty), never overwrite it regardless of other flags.
|
|
51
|
+
if (relPath.includes('phases' + path.sep) || relPath.includes('phases/')) {
|
|
52
|
+
const trimmedExisting = existing.trim();
|
|
53
|
+
if (trimmedExisting !== '' && trimmedExisting !== incoming.trim()) {
|
|
54
|
+
results.skipped.push(relPath);
|
|
55
|
+
results.manifest[relPath] = hashContent(existing);
|
|
56
|
+
console.log(` Preserved customized phase: ${displayPath}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
if (existing === incoming) {
|
|
49
62
|
results.skipped.push(relPath);
|
|
50
63
|
results.manifest[relPath] = incomingHash;
|
package/lib/omega-setup.js
CHANGED
|
@@ -7,17 +7,18 @@ const OMEGA_HOME = path.join(os.homedir(), '.claude-cabinet');
|
|
|
7
7
|
const VENV_DIR = path.join(OMEGA_HOME, 'omega-venv');
|
|
8
8
|
const VENV_PYTHON = path.join(VENV_DIR, 'bin', 'python3');
|
|
9
9
|
|
|
10
|
-
// Ordered by preference:
|
|
10
|
+
// Ordered by preference: 3.13 first (ONNX runtime segfaults on 3.14 during
|
|
11
|
+
// shutdown — exit 139, triggers macOS crash dialogs). 3.14 last as fallback.
|
|
11
12
|
// Apple Silicon paths, then Intel Mac paths, then PATH fallback.
|
|
12
13
|
const PYTHON_CANDIDATES = [
|
|
13
|
-
'/opt/homebrew/opt/python@3.14/bin/python3.14',
|
|
14
14
|
'/opt/homebrew/opt/python@3.13/bin/python3.13',
|
|
15
15
|
'/opt/homebrew/opt/python@3.12/bin/python3.12',
|
|
16
16
|
'/opt/homebrew/opt/python@3.11/bin/python3.11',
|
|
17
|
-
'/
|
|
17
|
+
'/opt/homebrew/opt/python@3.14/bin/python3.14',
|
|
18
18
|
'/usr/local/opt/python@3.13/bin/python3.13',
|
|
19
19
|
'/usr/local/opt/python@3.12/bin/python3.12',
|
|
20
20
|
'/usr/local/opt/python@3.11/bin/python3.11',
|
|
21
|
+
'/usr/local/opt/python@3.14/bin/python3.14',
|
|
21
22
|
'/opt/homebrew/bin/python3',
|
|
22
23
|
'/usr/local/bin/python3',
|
|
23
24
|
];
|
package/package.json
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work tracker server — serves the work tracker UI and provides
|
|
3
|
+
* read/write access to pib.db (projects and actions).
|
|
4
|
+
*
|
|
5
|
+
* Usage: node work-tracker-server.mjs [--port 3458] [--db path/to/pib.db]
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import Database from 'better-sqlite3';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PORT = parseInt(process.env.PORT || process.argv.find((_, i, a) => a[i - 1] === '--port') || '3458');
|
|
17
|
+
const DB_PATH = resolve(process.argv.find((_, i, a) => a[i - 1] === '--db') || 'pib.db');
|
|
18
|
+
|
|
19
|
+
if (!existsSync(DB_PATH)) {
|
|
20
|
+
console.error(`Database not found: ${DB_PATH}`);
|
|
21
|
+
console.error('Run: node scripts/pib-db.js init');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const db = new Database(DB_PATH, { readonly: false });
|
|
26
|
+
db.pragma('journal_mode = WAL');
|
|
27
|
+
|
|
28
|
+
function json(res, data, status = 200) {
|
|
29
|
+
res.writeHead(status, {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
'Access-Control-Allow-Origin': '*',
|
|
32
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
|
|
33
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
34
|
+
});
|
|
35
|
+
res.end(JSON.stringify(data));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readBody(req) {
|
|
39
|
+
let body = '';
|
|
40
|
+
for await (const chunk of req) body += chunk;
|
|
41
|
+
return JSON.parse(body);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = createServer(async (req, res) => {
|
|
45
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
46
|
+
|
|
47
|
+
if (req.method === 'OPTIONS') {
|
|
48
|
+
res.writeHead(204, {
|
|
49
|
+
'Access-Control-Allow-Origin': '*',
|
|
50
|
+
'Access-Control-Allow-Methods': 'GET, POST, PATCH, OPTIONS',
|
|
51
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
52
|
+
});
|
|
53
|
+
return res.end();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Serve HTML
|
|
58
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
59
|
+
const html = await readFile(join(__dirname, 'work-tracker-ui.html'), 'utf-8');
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
61
|
+
return res.end(html);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// GET /api/projects — list projects with action counts
|
|
65
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
66
|
+
const status = url.searchParams.get('status') || 'active';
|
|
67
|
+
const rows = db.prepare(`
|
|
68
|
+
SELECT p.*,
|
|
69
|
+
(SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.completed = 0 AND a.deleted_at IS NULL) as open_actions,
|
|
70
|
+
(SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.completed = 1) as done_actions,
|
|
71
|
+
(SELECT COUNT(*) FROM actions a WHERE a.project_fid = p.fid AND a.deleted_at IS NULL) as total_actions
|
|
72
|
+
FROM projects p
|
|
73
|
+
WHERE p.deleted_at IS NULL AND p.status = ?
|
|
74
|
+
ORDER BY p.created DESC
|
|
75
|
+
`).all(status);
|
|
76
|
+
return json(res, rows);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// GET /api/actions — list actions, optionally filtered
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/api/actions') {
|
|
81
|
+
const project = url.searchParams.get('project');
|
|
82
|
+
const status = url.searchParams.get('status'); // open, done, all
|
|
83
|
+
let query = `
|
|
84
|
+
SELECT a.*, p.name as project_name
|
|
85
|
+
FROM actions a
|
|
86
|
+
LEFT JOIN projects p ON a.project_fid = p.fid
|
|
87
|
+
WHERE a.deleted_at IS NULL
|
|
88
|
+
`;
|
|
89
|
+
const params = [];
|
|
90
|
+
|
|
91
|
+
if (project) {
|
|
92
|
+
query += ' AND a.project_fid = ?';
|
|
93
|
+
params.push(project);
|
|
94
|
+
}
|
|
95
|
+
if (status === 'open') {
|
|
96
|
+
query += ' AND a.completed = 0';
|
|
97
|
+
} else if (status === 'done') {
|
|
98
|
+
query += ' AND a.completed = 1';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
query += ' ORDER BY a.completed ASC, a.flagged DESC, a.sort_order ASC, a.created DESC';
|
|
102
|
+
return json(res, db.prepare(query).all(...params));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// GET /api/action/:fid — single action with full notes
|
|
106
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/action/')) {
|
|
107
|
+
const fid = decodeURIComponent(url.pathname.slice('/api/action/'.length));
|
|
108
|
+
const row = db.prepare(`
|
|
109
|
+
SELECT a.*, p.name as project_name
|
|
110
|
+
FROM actions a
|
|
111
|
+
LEFT JOIN projects p ON a.project_fid = p.fid
|
|
112
|
+
WHERE a.fid = ?
|
|
113
|
+
`).get(fid);
|
|
114
|
+
if (!row) return json(res, { error: 'Not found' }, 404);
|
|
115
|
+
return json(res, row);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// PATCH /api/action/:fid — update action fields
|
|
119
|
+
if (req.method === 'PATCH' && url.pathname.startsWith('/api/action/')) {
|
|
120
|
+
const fid = decodeURIComponent(url.pathname.slice('/api/action/'.length));
|
|
121
|
+
const body = await readBody(req);
|
|
122
|
+
const allowed = ['text', 'status', 'completed', 'flagged', 'notes', 'due', 'tags', 'project_fid'];
|
|
123
|
+
const sets = [];
|
|
124
|
+
const params = [];
|
|
125
|
+
for (const [key, val] of Object.entries(body)) {
|
|
126
|
+
if (!allowed.includes(key)) continue;
|
|
127
|
+
sets.push(`${key} = ?`);
|
|
128
|
+
params.push(val);
|
|
129
|
+
}
|
|
130
|
+
if (body.completed === 1 && !body.completed_at) {
|
|
131
|
+
sets.push('completed_at = ?');
|
|
132
|
+
params.push(new Date().toISOString().split('T')[0]);
|
|
133
|
+
}
|
|
134
|
+
if (sets.length === 0) return json(res, { error: 'Nothing to update' }, 400);
|
|
135
|
+
params.push(fid);
|
|
136
|
+
db.prepare(`UPDATE actions SET ${sets.join(', ')} WHERE fid = ?`).run(...params);
|
|
137
|
+
return json(res, { ok: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PATCH /api/project/:fid — update project fields
|
|
141
|
+
if (req.method === 'PATCH' && url.pathname.startsWith('/api/project/')) {
|
|
142
|
+
const fid = decodeURIComponent(url.pathname.slice('/api/project/'.length));
|
|
143
|
+
const body = await readBody(req);
|
|
144
|
+
const allowed = ['name', 'status', 'area', 'notes', 'due'];
|
|
145
|
+
const sets = [];
|
|
146
|
+
const params = [];
|
|
147
|
+
for (const [key, val] of Object.entries(body)) {
|
|
148
|
+
if (!allowed.includes(key)) continue;
|
|
149
|
+
sets.push(`${key} = ?`);
|
|
150
|
+
params.push(val);
|
|
151
|
+
}
|
|
152
|
+
if (body.status === 'done' && !body.completed_at) {
|
|
153
|
+
sets.push('completed_at = ?');
|
|
154
|
+
params.push(new Date().toISOString().split('T')[0]);
|
|
155
|
+
}
|
|
156
|
+
if (sets.length === 0) return json(res, { error: 'Nothing to update' }, 400);
|
|
157
|
+
params.push(fid);
|
|
158
|
+
db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE fid = ?`).run(...params);
|
|
159
|
+
return json(res, { ok: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// GET /api/stats — dashboard summary
|
|
163
|
+
if (req.method === 'GET' && url.pathname === '/api/stats') {
|
|
164
|
+
const projects = db.prepare(`
|
|
165
|
+
SELECT status, COUNT(*) as count FROM projects WHERE deleted_at IS NULL GROUP BY status
|
|
166
|
+
`).all();
|
|
167
|
+
const actions = db.prepare(`
|
|
168
|
+
SELECT
|
|
169
|
+
SUM(CASE WHEN completed = 0 THEN 1 ELSE 0 END) as open,
|
|
170
|
+
SUM(CASE WHEN completed = 1 THEN 1 ELSE 0 END) as done,
|
|
171
|
+
SUM(CASE WHEN flagged = 1 AND completed = 0 THEN 1 ELSE 0 END) as flagged
|
|
172
|
+
FROM actions WHERE deleted_at IS NULL
|
|
173
|
+
`).get();
|
|
174
|
+
return json(res, { projects, actions });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
res.writeHead(404);
|
|
178
|
+
res.end('Not found');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(err);
|
|
181
|
+
json(res, { error: err.message }, 500);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
server.listen(PORT, () => {
|
|
186
|
+
console.log(`Work tracker at http://localhost:${PORT}`);
|
|
187
|
+
console.log(`Database: ${DB_PATH}`);
|
|
188
|
+
});
|