create-claude-cabinet 0.8.4 → 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 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
- // --- Clean up files removed upstream ---
576
- // Phase files are excluded from the manifest (they're user-customized),
577
- // so skip them during cleanup even if they were in the old manifest.
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
- let totalRemoved = 0;
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
- if (!flags.dryRun) fs.unlinkSync(fullPath);
587
- totalRemoved++;
693
+ toRemove.push(oldPath);
588
694
  }
589
695
  }
590
696
  }
591
- if (totalRemoved > 0) {
592
- console.log(` 🧹 Removed ${totalRemoved} file${totalRemoved === 1 ? '' : 's'} no longer in upstream`);
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;
@@ -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: stable Cellar symlinks first, newest Python first.
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
- '/usr/local/opt/python@3.14/bin/python3.14',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -149,6 +149,7 @@ def cmd_store():
149
149
  text: the memory content (required)
150
150
  type: event_type for omega (default: "lesson")
151
151
  tags: list of tags (stored in metadata, optional)
152
+ project: project name (default: basename of cwd)
152
153
  """
153
154
  data = _read_stdin()
154
155
  omega = _import_omega()
@@ -163,12 +164,14 @@ def cmd_store():
163
164
 
164
165
  try:
165
166
  event_type = data.get("type", "lesson")
167
+ project = data.get("project", os.path.basename(os.getcwd()))
166
168
  metadata = {}
167
169
  if data.get("tags"):
168
170
  metadata["tags"] = data["tags"]
169
171
  result = omega.store(
170
172
  text,
171
173
  event_type=event_type,
174
+ project=project,
172
175
  metadata=metadata if metadata else None,
173
176
  )
174
177
  _output({"ok": True, "id": result if isinstance(result, str) else None})
@@ -177,11 +180,17 @@ def cmd_store():
177
180
 
178
181
 
179
182
  def cmd_query():
180
- """Query memories by text.
183
+ """Query memories by text with tiered project scoping.
181
184
 
182
185
  Reads JSON from stdin with fields:
183
186
  text: the query text (required)
184
187
  limit: max results (default: 5)
188
+ type: filter by event_type (optional)
189
+ project: project name for scoping (default: basename of cwd)
190
+ scope: "project" (default), "all", or "tiered"
191
+ - "project": only memories from this project
192
+ - "all": memories from all projects
193
+ - "tiered": project-scoped first, then cross-project to fill limit
185
194
  """
186
195
  data = _read_stdin()
187
196
  omega = _import_omega()
@@ -197,29 +206,131 @@ def cmd_query():
197
206
  try:
198
207
  limit = data.get("limit", 5)
199
208
  event_type = data.get("type")
200
- results = omega.query(
201
- text,
202
- limit=limit,
203
- event_type=event_type,
204
- )
209
+ project = data.get("project", os.path.basename(os.getcwd()))
210
+ scope = data.get("scope", "tiered")
211
+
212
+ def _parse_results(results):
213
+ """Normalize query results into a list of dicts.
214
+
215
+ omega.query() returns a formatted markdown string. We parse it
216
+ back into structured data. Also queries the DB to get the project
217
+ field for each memory (not included in the formatted output).
218
+ """
219
+ if isinstance(results, (list, tuple)):
220
+ out = []
221
+ for r in results:
222
+ if isinstance(r, dict):
223
+ out.append({
224
+ "text": r.get("content", r.get("text", str(r))),
225
+ "type": r.get("event_type", "unknown"),
226
+ "score": r.get("score", 0),
227
+ "project": r.get("project", ""),
228
+ "id": r.get("node_id", ""),
229
+ })
230
+ else:
231
+ out.append({"text": str(r)})
232
+ return out
233
+
234
+ if not isinstance(results, str) or not results.strip():
235
+ return []
236
+
237
+ # Parse formatted markdown from omega.query()
238
+ # Format: ## N. [type] `mem-xxx` (str: 0.xx)\ncontent\n*timestamp*
239
+ import re
240
+ entries = []
241
+ blocks = re.split(r'\n## \d+\.', results)
242
+ for block in blocks:
243
+ block = block.strip()
244
+ if not block:
245
+ continue
246
+ id_match = re.search(r'`(mem-[a-f0-9]+)`', block)
247
+ type_match = re.search(r'\[(\w+)\]', block)
248
+ score_match = re.search(r'str: ([\d.]+)', block)
249
+ # Content is everything after the first line, minus the timestamp
250
+ lines = block.split('\n')
251
+ content_lines = [l for l in lines[1:] if not l.startswith('*')]
252
+ content = '\n'.join(content_lines).strip()
253
+
254
+ if not id_match:
255
+ continue # skip header/noise lines
256
+ entry = {
257
+ "text": content,
258
+ "type": type_match.group(1) if type_match else "unknown",
259
+ "score": float(score_match.group(1)) if score_match else 0,
260
+ "id": id_match.group(1) if id_match else "",
261
+ "project": "",
262
+ }
263
+ entries.append(entry)
264
+
265
+ # Enrich with project from DB
266
+ if entries:
267
+ try:
268
+ import sqlite3
269
+ db_path = os.path.expanduser("~/.omega/omega.db")
270
+ conn = sqlite3.connect(db_path)
271
+ ids = [e["id"] for e in entries if e["id"]]
272
+ if ids:
273
+ placeholders = ",".join("?" * len(ids))
274
+ rows = conn.execute(
275
+ f"SELECT node_id, project FROM memories WHERE node_id IN ({placeholders})",
276
+ ids,
277
+ ).fetchall()
278
+ proj_map = {r[0]: r[1] or "" for r in rows}
279
+ for e in entries:
280
+ e["project"] = proj_map.get(e["id"], "")
281
+ conn.close()
282
+ except Exception:
283
+ pass # D3: project enrichment is best-effort
284
+
285
+ return entries
286
+
287
+ # All scopes fetch from omega broadly, then filter in the adapter.
288
+ # omega.query() doesn't reliably pass project_path to the store,
289
+ # so we do project filtering ourselves.
290
+ over_fetch = limit * 3 # fetch extra so filtering still yields enough
291
+
292
+ if scope == "all":
293
+ results = omega.query(text, limit=over_fetch, event_type=event_type)
294
+ parsed = _parse_results(results)[:limit]
295
+ _output({"ok": True, "results": parsed, "scope": "all"})
296
+
297
+ elif scope == "project":
298
+ results = omega.query(text, limit=over_fetch, event_type=event_type)
299
+ parsed = _parse_results(results)
300
+ filtered = [m for m in parsed if m.get("project") == project][:limit]
301
+ _output({"ok": True, "results": filtered, "scope": "project", "project": project})
302
+
303
+ else: # tiered (default)
304
+ results = omega.query(text, limit=over_fetch, event_type=event_type)
305
+ parsed = _parse_results(results)
306
+
307
+ # Tier 1: project-scoped
308
+ project_memories = [m for m in parsed if m.get("project") == project][:limit]
309
+ for m in project_memories:
310
+ m["tier"] = "project"
311
+
312
+ # Tier 2: cross-project to fill remaining slots
313
+ remaining = limit - len(project_memories)
314
+ cross_memories = []
315
+ if remaining > 0:
316
+ seen_ids = {m["id"] for m in project_memories if m["id"]}
317
+ for m in parsed:
318
+ if m.get("project") != project and m.get("id") not in seen_ids:
319
+ m["tier"] = "cross-project"
320
+ cross_memories.append(m)
321
+ if len(cross_memories) >= remaining:
322
+ break
323
+
324
+ combined = project_memories + cross_memories
325
+ _output({
326
+ "ok": True,
327
+ "results": combined,
328
+ "scope": "tiered",
329
+ "project": project,
330
+ "project_count": len(project_memories),
331
+ "cross_project_count": len(cross_memories),
332
+ })
205
333
 
206
- # query() returns a formatted string of results
207
- if isinstance(results, str):
208
- _output({"ok": True, "results": results})
209
- elif isinstance(results, (list, tuple)):
210
- memories = []
211
- for r in results:
212
- if isinstance(r, dict):
213
- memories.append({
214
- "text": r.get("content", r.get("text", str(r))),
215
- "type": r.get("event_type", "unknown"),
216
- "score": r.get("score", 0),
217
- })
218
- else:
219
- memories.append({"text": str(r)})
220
- _output({"ok": True, "memories": memories})
221
- else:
222
- _output({"ok": True, "results": str(results)})
223
334
  except Exception as e:
224
335
  _error(f"query failed: {e}")
225
336
 
@@ -278,6 +389,7 @@ def cmd_list():
278
389
  Returns all memories so delete can target the correct ID.
279
390
  Reads JSON from stdin with optional fields:
280
391
  type: filter by event_type (optional)
392
+ project: filter by project (optional)
281
393
  limit: max results (default: 50)
282
394
  """
283
395
  data = _read_stdin()
@@ -291,21 +403,28 @@ def cmd_list():
291
403
 
292
404
  conn = sqlite3.connect(db_path)
293
405
  event_type = data.get("type")
406
+ project = data.get("project")
294
407
  limit = data.get("limit", 50)
295
408
 
409
+ where_clauses = []
410
+ params = []
296
411
  if event_type:
297
- rows = conn.execute(
298
- "SELECT node_id, content, event_type, created_at FROM memories WHERE event_type = ? ORDER BY created_at DESC LIMIT ?",
299
- (event_type, limit),
300
- ).fetchall()
301
- else:
302
- rows = conn.execute(
303
- "SELECT node_id, content, event_type, created_at FROM memories ORDER BY created_at DESC LIMIT ?",
304
- (limit,),
305
- ).fetchall()
412
+ where_clauses.append("event_type = ?")
413
+ params.append(event_type)
414
+ if project:
415
+ where_clauses.append("project = ?")
416
+ params.append(project)
417
+
418
+ where = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
419
+ params.append(limit)
420
+
421
+ rows = conn.execute(
422
+ f"SELECT node_id, content, event_type, project, created_at FROM memories {where} ORDER BY created_at DESC LIMIT ?",
423
+ params,
424
+ ).fetchall()
306
425
 
307
426
  memories = [
308
- {"id": r[0], "text": r[1], "type": r[2], "created": r[3]}
427
+ {"id": r[0], "text": r[1], "type": r[2], "project": r[3] or "", "created": r[4]}
309
428
  for r in rows
310
429
  ]
311
430
  _output({"ok": True, "memories": memories, "count": len(memories)})