claudenv 1.2.4 → 1.3.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.
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: vibe-decisions
3
+ description: |
4
+ Trigger before writing code that involves a non-trivial technical
5
+ choice (library, algorithm, data structure, architecture pattern,
6
+ sync vs async, correctness vs performance). Output a brief overview
7
+ with concrete trade-offs and 1-2 alternatives, then log the decision.
8
+ Two modes: auto-log (no pauses) inside `claudenv loop`, pause-and-ask
9
+ in interactive Claude Code. Also triggers on: "почему X?", "X vs Y?",
10
+ "/why", "/deeper", "/decisions", "alternatives?", "trade-offs?".
11
+ Do NOT trigger for trivial syntax, naming, or formatting choices.
12
+ ---
13
+
14
+ # Vibe decisions
15
+
16
+ ## Mode detection (FIRST step every time)
17
+
18
+ Check the system prompt for the marker `Vibe-decisions mode (loop)`.
19
+
20
+ - **Marker present** → AUTO-LOG mode. Pick the approach, write the decision file, continue. NEVER pause. NEVER ask "делать так?". This is `claudenv loop` and pause violates autonomy=law.
21
+ - **Marker absent** → INTERACTIVE mode. Output brief overview, wait for user confirmation or `/deeper`.
22
+
23
+ Both modes share the same trigger criteria, file format, and slash-command behavior. Only output shape and pause behavior differ.
24
+
25
+ ## Trigger criteria
26
+
27
+ Trigger if the decision involves:
28
+
29
+ - choosing a library or framework from 2+ viable options
30
+ - choosing an algorithm or data structure with meaningful trade-offs
31
+ - architecture pattern (sync/async, push/pull, where state lives, monolith/services)
32
+ - correctness vs performance vs simplicity trade-off
33
+ - anything where reasonable engineers would disagree
34
+
35
+ DO NOT trigger for:
36
+
37
+ - syntax-level choices in a single language (for vs map, ternary vs if)
38
+ - obvious choices (JSON for a config file, regex for simple pattern)
39
+ - pure refactoring within established patterns
40
+ - when the user typed `/just-code` in the previous turn
41
+
42
+ ## Step 1 — Check memory before deciding
43
+
44
+ Before writing brief or auto-log, read related precedents:
45
+
46
+ - `view` `/memories/decisions/` — list files; if any topic looks related, read it
47
+ - Mention the precedent briefly: "Раньше для X выбирали Y (см. <file>)"
48
+
49
+ ## Step 2A — AUTO-LOG mode output (inside `claudenv loop`)
50
+
51
+ Output exactly this shape in the user's language, then continue with code in the same turn:
52
+
53
+ ```
54
+ **Выбрано:** <approach>
55
+ **Почему:** <one sentence>
56
+ **Альтернативы:** <A> — <terse trade-off>; <B> — <terse trade-off>
57
+ **Лог:** /memories/decisions/<YYYY-MM-DD>-<topic-slug>.md (scope: <global|project>)
58
+ ```
59
+
60
+ Immediately after the block, use the Write tool to create the decision file (see Step 4 for format). Then proceed with the actual code. Do NOT pause.
61
+
62
+ ## Step 2B — INTERACTIVE mode output (no loop)
63
+
64
+ Output this shape and STOP. Wait for the user.
65
+
66
+ ```
67
+ **Подход:** <approach>
68
+ **Почему для этой задачи:** <1-2 lines, concrete to the task>
69
+ **Минусы:** <1-2 lines, concrete to the task>
70
+ **Альтернативы:** <A> — <one sentence>; <B> — <one sentence>
71
+ ```
72
+
73
+ Then ask: "Делать так, или разобрать варианты подробнее?"
74
+
75
+ DO NOT proceed with code until the user confirms OR asks for deep dive.
76
+
77
+ ## Step 3 — Deep dive (on `/deeper` or "расскажи подробнее")
78
+
79
+ Expand the most recent decision into:
80
+
81
+ 1. **Концептуально** — what it is and the problem it solves (3-5 lines)
82
+ 2. **Как работает** — simplified model (5-10 lines, optionally a code sketch)
83
+ 3. **2-3 варианта реализации** — for each: code + 1-2 lines of trade-offs
84
+ 4. **Канон** — 2-4 references. First check `~/.claudenv/memories/canon/index.yaml` for a topic match. If a match exists — cite those. If not — WebSearch for authoritative sources and after the response suggest `claudenv canon add <topic> <url> --why "..."`.
85
+
86
+ After deep dive, in auto-log mode update the existing decision file's `deep_dive_done: yes` field. In interactive mode wait for confirmation.
87
+
88
+ ## Step 4 — Write the decision file
89
+
90
+ Path:
91
+
92
+ - `scope: global` (default) → `~/.claudenv/memories/decisions/<YYYY-MM-DD>-<topic-slug>.md`
93
+ - `scope: project` → `<cwd>/.claude/memories/decisions/<YYYY-MM-DD>-<topic-slug>.md`
94
+
95
+ **Choose scope BEFORE writing — don't rely on hook to rewrite.** Project scope if the choice mentions project-only artifacts (module name, env var, deployment target, internal service). Otherwise global.
96
+
97
+ File content:
98
+
99
+ ```
100
+ ---
101
+ date: <ISO 8601 datetime with timezone>
102
+ project: <basename of cwd, or "(none)" if no project context>
103
+ topic: <plain-language summary of what was decided>
104
+ chose: <approach>
105
+ alternatives_considered: [<A>, <B>]
106
+ reason: <one sentence why>
107
+ deep_dive_done: <yes|no>
108
+ sources_consulted: [<canon-id-or-url>, ...]
109
+ scope: <global|project>
110
+ ---
111
+
112
+ <optional body — caveats, follow-ups, links>
113
+
114
+ __VIBE_DECISION__
115
+ ```
116
+
117
+ The `__VIBE_DECISION__` marker on the last line lets the `decisions-logger` hook detect the file even if the path check fails. Always include it.
118
+
119
+ ## Slash-commands the skill responds to
120
+
121
+ - `/why <X>` — explain X briefly without committing a decision; do NOT write a decision file
122
+ - `/deeper` — deep dive on the most recent decision (Step 3)
123
+ - `/decisions [N]` — execute Bash `claudenv decisions list --limit ${N:-10}` and show output
124
+ - `/canon [<topic>]` — execute Bash `claudenv canon list <topic>` and show output
125
+ - `/just-code` — suppress vibe-decisions on the NEXT response only; resume on the one after
126
+
127
+ Trigger phrases that also count (in any language): "расскажи подробнее", "почему именно X?", "X vs Y?", "alternatives?", "trade-offs?".
@@ -0,0 +1,41 @@
1
+ # Deep dive template
2
+
3
+ Used by the `vibe-decisions` skill when the user requests `/deeper` or asks "расскажи подробнее", "X vs Y?", "trade-offs?".
4
+
5
+ Fill each section concretely against the current task — generic prose is the failure mode here.
6
+
7
+ ## 1. Концептуально (3-5 строк)
8
+
9
+ What this approach is and what problem it solves. Mention the canonical name and 1-2 alternatives by name (so the reader can search later). Avoid marketing language.
10
+
11
+ ## 2. Как работает (5-10 строк, опц. code sketch)
12
+
13
+ A simplified mental model. If a 10-15 line code sketch clarifies the mechanism, include it. Otherwise prose. Mention 1-2 invariants that matter — what the approach guarantees and what it explicitly does not.
14
+
15
+ ## 3. Варианты реализации (2-3)
16
+
17
+ For each variant:
18
+
19
+ ### Variant N — <name>
20
+
21
+ ```<lang>
22
+ # minimal sketch, 5-15 lines
23
+ ```
24
+
25
+ **Когда подходит:** <one line>
26
+ **Когда не подходит:** <one line>
27
+
28
+ ## 4. Канон (2-4 ссылки)
29
+
30
+ Priority: matches from `~/.claudenv/memories/canon/index.yaml` by topic. If no match, WebSearch and propose `claudenv canon add` after the response.
31
+
32
+ Format each entry:
33
+
34
+ - **<title>** — <author or venue> — <1 line why>
35
+ <url>
36
+
37
+ ## After deep dive
38
+
39
+ In AUTO-LOG mode: update the existing decision file to set `deep_dive_done: yes` and append source URLs to `sources_consulted`.
40
+
41
+ In INTERACTIVE mode: ask the user "Принимаем этот подход?" before writing/updating the decision file.
@@ -0,0 +1,16 @@
1
+ # claudenv global config.
2
+ # Created by `claudenv install` if missing; preserved on subsequent installs.
3
+
4
+ # Default model for `claudenv loop` if --model is not passed.
5
+ # Options: opus, sonnet, haiku
6
+ default_model: sonnet
7
+
8
+ # Auto-commit ~/.claudenv/ after each session (planned for 1.4 — currently unused).
9
+ auto_commit: false
10
+
11
+ # Memory scope default when vibe-decisions can't infer.
12
+ # Options: global, project
13
+ default_scope: global
14
+
15
+ # INDEX.md regeneration: maximum number of recent decisions to surface.
16
+ index_recent_limit: 5
@@ -0,0 +1,25 @@
1
+ # claudenv memory — INDEX
2
+
3
+ > Auto-generated by `claudenv hook regen-index` after each session and after each `claudenv loop` iteration. Hand-edits will be overwritten.
4
+ > Last regenerated: <never>
5
+
6
+ ## Recent decisions
7
+
8
+ _No decisions logged yet. Make a non-trivial technical choice in a session — the `vibe-decisions` skill will log it here._
9
+
10
+ ## Active preferences
11
+
12
+ _No `user/preferences.md` yet. Create one at `~/.claudenv/memories/user/preferences.md` (template at `preferences.md.example`)._
13
+
14
+ ## Active projects
15
+
16
+ _Per-project facts live in each project's `.claude/memories/project.md`. This index lists projects seen recently._
17
+
18
+ ---
19
+
20
+ Files in this directory:
21
+
22
+ - `INDEX.md` — this file
23
+ - `decisions/` — global cross-project tech decisions
24
+ - `canon/` — personal canon of references
25
+ - `user/` — cross-project preferences
@@ -0,0 +1,21 @@
1
+ # Личный канон. Только то что вы прочитали и сочли важным.
2
+ # Добавляйте через: claudenv canon add <topic> <url> --why "<reason>"
3
+ # Используется vibe-decisions skill в /deeper для подбора ссылок.
4
+
5
+ # Стартовое наполнение пустое — канон растёт от реальных decision-моментов.
6
+ # Пример (раскомментируйте и адаптируйте):
7
+
8
+ # distributed_locks:
9
+ # - title: "How to do distributed locking"
10
+ # author: Martin Kleppmann
11
+ # url: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
12
+ # why: "обязательное чтение перед выбором Redis для locks"
13
+ # added: 2026-05-27
14
+
15
+ # raft_consensus:
16
+ # - title: "In Search of an Understandable Consensus Algorithm"
17
+ # authors: [Ongaro, Ousterhout]
18
+ # venue: "USENIX ATC 2014"
19
+ # url: https://raft.github.io/raft.pdf
20
+ # why: "оригинальный paper"
21
+ # added: 2026-05-27
@@ -0,0 +1,16 @@
1
+ # User preferences
2
+
3
+ Copy this file to `preferences.md` (in the same directory) and edit. The `vibe-decisions` skill reads it and uses it as context when suggesting approaches.
4
+
5
+ Format: free-form markdown, but short lines (one preference per line) work best for cache stability.
6
+
7
+ ## Examples
8
+
9
+ - Default branch naming: `<initials>/<short-desc>`
10
+ - Commit style: conventional commits, lowercase subject
11
+ - Editor: helix
12
+ - Package manager: pnpm (Node), uv (Python), cargo (Rust)
13
+ - TypeScript: strict + noUncheckedIndexedAccess
14
+ - Python: type hints required, no `Any` без TODO
15
+ - Prefer integration tests с реальной БД over heavy mocking
16
+ - Не использовать `as` import unless prefix collision
package/src/autonomy.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  generateAuditLogHook,
10
10
  generateAliases,
11
11
  generateCIWorkflow,
12
+ isGlobalInstall,
12
13
  } from './hooks-gen.js';
13
14
  import { detectTechStack } from './detector.js';
14
15
 
@@ -25,10 +26,14 @@ export async function generateAutonomyConfig(profileName, projectDir, options =
25
26
  const profile = getProfile(profileName);
26
27
  const detected = options.detected || (await detectTechStack(projectDir));
27
28
 
29
+ // Hook commands need `claudenv` on PATH. If user installed via npm install -g
30
+ // we can call `claudenv` directly; otherwise fall back to `npx claudenv`.
31
+ const claudenvCmd = (await isGlobalInstall()) ? 'claudenv' : 'npx claudenv';
32
+
28
33
  const files = [
29
34
  {
30
35
  path: '.claude/settings.json',
31
- content: generateSettingsJson(profile, detected),
36
+ content: generateSettingsJson(profile, detected, { claudenvCmd }),
32
37
  },
33
38
  {
34
39
  path: '.claude/hooks/pre-tool-use.sh',
@@ -63,6 +68,7 @@ export function printSecuritySummary(profile) {
63
68
  console.log(`\n Security summary — ${profile.name} profile\n`);
64
69
  console.log(` ${'─'.repeat(50)}`);
65
70
 
71
+ console.log(` Default model: ${profile.model || 'sonnet'}`);
66
72
  console.log(` Skip permissions: ${profile.skipPermissions ? 'YES (--dangerously-skip-permissions)' : 'No'}`);
67
73
  console.log(` Credential policy: ${profile.credentialPolicy}`);
68
74
 
package/src/canon.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * CLI: `claudenv canon add/list/search/prune`
3
+ *
4
+ * Owns ~/.claudenv/memories/canon/index.yaml — a topic-keyed list of links.
5
+ * Format intentionally simple (flat YAML) so it's human-editable.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
+ import { dirname } from 'node:path';
10
+ import { canonIndexPath } from './memory-paths.js';
11
+
12
+ /**
13
+ * Read the canon yaml as a JS object. Tolerant to missing or empty files.
14
+ *
15
+ * Parser is intentionally minimal — full YAML would be overkill for a flat
16
+ * topic → [entries] mapping and would add a dependency.
17
+ */
18
+ async function readCanon() {
19
+ let text;
20
+ try {
21
+ text = await readFile(canonIndexPath(), 'utf-8');
22
+ } catch {
23
+ return {};
24
+ }
25
+
26
+ const result = {};
27
+ let currentTopic = null;
28
+ let currentEntry = null;
29
+
30
+ for (const rawLine of text.split('\n')) {
31
+ const line = rawLine.replace(/\s+$/, '');
32
+ if (!line || line.startsWith('#')) continue;
33
+
34
+ // Topic key: `topic_name:` at column 0
35
+ const topicMatch = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*$/.exec(line);
36
+ if (topicMatch) {
37
+ currentTopic = topicMatch[1];
38
+ result[currentTopic] = [];
39
+ currentEntry = null;
40
+ continue;
41
+ }
42
+
43
+ // List entry start: ` - key: value` or ` - key:` (multiline value)
44
+ const entryStartMatch = /^\s+-\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
45
+ if (entryStartMatch && currentTopic) {
46
+ currentEntry = {};
47
+ currentEntry[entryStartMatch[1]] = unquote(entryStartMatch[2]);
48
+ result[currentTopic].push(currentEntry);
49
+ continue;
50
+ }
51
+
52
+ // Subsequent key in same entry: ` key: value`
53
+ const subKeyMatch = /^\s+([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
54
+ if (subKeyMatch && currentEntry) {
55
+ currentEntry[subKeyMatch[1]] = unquote(subKeyMatch[2]);
56
+ continue;
57
+ }
58
+ }
59
+
60
+ return result;
61
+ }
62
+
63
+ function unquote(s) {
64
+ const trimmed = s.trim();
65
+ if (!trimmed) return '';
66
+ if (
67
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
68
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
69
+ ) {
70
+ return trimmed.slice(1, -1);
71
+ }
72
+ // Inline arrays — pass through as raw string for now (rare in canon).
73
+ return trimmed;
74
+ }
75
+
76
+ /**
77
+ * Append an entry to the canon. Creates the topic group if absent.
78
+ *
79
+ * @param {object} opts
80
+ * @param {string} opts.topic - Topic slug
81
+ * @param {string} opts.url - Required
82
+ * @param {string} opts.why - Required
83
+ * @param {string} [opts.title]
84
+ * @param {string} [opts.author]
85
+ */
86
+ export async function canonAdd({ topic, url, why, title, author }) {
87
+ if (!topic || !url || !why) {
88
+ throw new Error('claudenv canon add <topic> <url> --why "<reason>" required');
89
+ }
90
+
91
+ const canon = await readCanon();
92
+ if (!canon[topic]) canon[topic] = [];
93
+
94
+ const entry = {
95
+ ...(title ? { title } : {}),
96
+ ...(author ? { author } : {}),
97
+ url,
98
+ why,
99
+ added: new Date().toISOString().slice(0, 10),
100
+ };
101
+
102
+ // Skip duplicates by URL within the same topic.
103
+ const dup = canon[topic].find((e) => e.url === url);
104
+ if (dup) return { added: false, reason: 'duplicate url in same topic', entry: dup };
105
+
106
+ canon[topic].push(entry);
107
+ await writeCanon(canon);
108
+ return { added: true, topic, entry };
109
+ }
110
+
111
+ /**
112
+ * List all topics with their entries, or filter by one topic.
113
+ */
114
+ export async function canonList(topic) {
115
+ const canon = await readCanon();
116
+ if (topic) {
117
+ return { [topic]: canon[topic] || [] };
118
+ }
119
+ return canon;
120
+ }
121
+
122
+ /**
123
+ * Substring search across title/why/url, returning matching entries grouped by topic.
124
+ */
125
+ export async function canonSearch(query) {
126
+ if (!query) throw new Error('claudenv canon search <query> required');
127
+ const q = query.toLowerCase();
128
+ const canon = await readCanon();
129
+ const result = {};
130
+ for (const [topic, entries] of Object.entries(canon)) {
131
+ const hits = entries.filter((e) =>
132
+ [e.title, e.why, e.url, e.author].some((v) => v && v.toLowerCase().includes(q))
133
+ );
134
+ if (hits.length) result[topic] = hits;
135
+ }
136
+ return result;
137
+ }
138
+
139
+ /**
140
+ * Find entries whose `added` date is older than `months` months — candidates
141
+ * for review. Returns an array of `{topic, entry}` items.
142
+ */
143
+ export async function canonPrune(months = 6) {
144
+ const cutoff = new Date();
145
+ cutoff.setMonth(cutoff.getMonth() - months);
146
+ const canon = await readCanon();
147
+ const stale = [];
148
+ for (const [topic, entries] of Object.entries(canon)) {
149
+ for (const entry of entries) {
150
+ if (!entry.added) {
151
+ stale.push({ topic, entry, reason: 'no `added` date' });
152
+ continue;
153
+ }
154
+ const added = new Date(entry.added);
155
+ if (!Number.isNaN(added.getTime()) && added < cutoff) {
156
+ stale.push({ topic, entry });
157
+ }
158
+ }
159
+ }
160
+ return stale;
161
+ }
162
+
163
+ /**
164
+ * Serialize canon back to YAML form. Comment-light, human-editable.
165
+ */
166
+ async function writeCanon(canon) {
167
+ const lines = [];
168
+ lines.push('# Личный канон. Добавляйте через: claudenv canon add <topic> <url> --why "<reason>"');
169
+ lines.push('');
170
+ for (const [topic, entries] of Object.entries(canon)) {
171
+ lines.push(`${topic}:`);
172
+ for (const e of entries) {
173
+ const keys = Object.keys(e);
174
+ if (keys.length === 0) continue;
175
+ const [firstKey, ...rest] = keys;
176
+ lines.push(` - ${firstKey}: ${quote(e[firstKey])}`);
177
+ for (const k of rest) {
178
+ lines.push(` ${k}: ${quote(e[k])}`);
179
+ }
180
+ }
181
+ lines.push('');
182
+ }
183
+ await mkdir(dirname(canonIndexPath()), { recursive: true });
184
+ await writeFile(canonIndexPath(), lines.join('\n'), 'utf-8');
185
+ }
186
+
187
+ function quote(v) {
188
+ if (typeof v !== 'string') return String(v);
189
+ if (/[:#"\n]/.test(v)) return JSON.stringify(v);
190
+ return v;
191
+ }
192
+
193
+ /**
194
+ * Format canon for terminal output.
195
+ */
196
+ export function formatCanon(canon) {
197
+ const topics = Object.keys(canon);
198
+ if (topics.length === 0) {
199
+ return 'Канон пуст. Добавь первые записи через `claudenv canon add <topic> <url> --why "..."`.';
200
+ }
201
+ const lines = [];
202
+ for (const t of topics) {
203
+ lines.push(`## ${t}`);
204
+ for (const e of canon[t] || []) {
205
+ const title = e.title || '(no title)';
206
+ const author = e.author ? ` — ${e.author}` : '';
207
+ lines.push(` - ${title}${author}`);
208
+ lines.push(` ${e.url}`);
209
+ if (e.why) lines.push(` why: ${e.why}`);
210
+ }
211
+ lines.push('');
212
+ }
213
+ return lines.join('\n').trimEnd();
214
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * CLI: `claudenv decisions list/show/search/archive`
3
+ *
4
+ * Reads decision files from both global (~/.claudenv/memories/decisions/) and
5
+ * the current project (.claude/memories/decisions/). Files outside these two
6
+ * roots are not considered decisions.
7
+ */
8
+
9
+ import { readFile, writeFile, readdir, mkdir, rename, stat } from 'node:fs/promises';
10
+ import { join, basename, dirname } from 'node:path';
11
+ import {
12
+ globalDecisionsDir,
13
+ projectDecisionsDir,
14
+ parseFrontmatter,
15
+ } from './memory-paths.js';
16
+
17
+ async function readDir(dir) {
18
+ try {
19
+ return await readdir(dir);
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ async function loadDecision(file, scopeHint) {
26
+ let text;
27
+ try {
28
+ text = await readFile(file, 'utf-8');
29
+ } catch {
30
+ return null;
31
+ }
32
+ const fm = parseFrontmatter(text) || {};
33
+ return {
34
+ file,
35
+ slug: basename(file, '.md'),
36
+ date: fm.date || null,
37
+ topic: fm.topic || basename(file, '.md'),
38
+ chose: fm.chose || '',
39
+ reason: fm.reason || '',
40
+ alternatives: fm.alternatives_considered || [],
41
+ scope: fm.scope || scopeHint,
42
+ deepDive: fm.deep_dive_done === 'yes' || fm.deep_dive_done === true,
43
+ text,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Collect decisions from global + project roots. Newest first by `date:`.
49
+ *
50
+ * @param {object} opts
51
+ * @param {string} [opts.cwd] - Project root (default: process.cwd())
52
+ * @param {'global'|'project'|'all'} [opts.scope] - Filter
53
+ */
54
+ export async function listDecisions({ cwd = process.cwd(), scope = 'all' } = {}) {
55
+ const out = [];
56
+
57
+ if (scope === 'global' || scope === 'all') {
58
+ for (const name of await readDir(globalDecisionsDir())) {
59
+ if (!name.endsWith('.md')) continue;
60
+ const d = await loadDecision(join(globalDecisionsDir(), name), 'global');
61
+ if (d) out.push(d);
62
+ }
63
+ }
64
+
65
+ if (scope === 'project' || scope === 'all') {
66
+ const pdir = projectDecisionsDir(cwd);
67
+ for (const name of await readDir(pdir)) {
68
+ if (!name.endsWith('.md')) continue;
69
+ const d = await loadDecision(join(pdir, name), 'project');
70
+ if (d) out.push(d);
71
+ }
72
+ }
73
+
74
+ out.sort((a, b) => {
75
+ if (!a.date && !b.date) return 0;
76
+ if (!a.date) return 1;
77
+ if (!b.date) return -1;
78
+ return b.date.localeCompare(a.date);
79
+ });
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Show one decision by slug or substring. Returns the full file text.
85
+ */
86
+ export async function showDecision(idOrSlug, { cwd = process.cwd() } = {}) {
87
+ if (!idOrSlug) throw new Error('Usage: claudenv decisions show <id-or-slug>');
88
+ const all = await listDecisions({ cwd });
89
+ // Exact slug match first, then substring on slug, then substring on topic.
90
+ let match = all.find((d) => d.slug === idOrSlug);
91
+ if (!match) match = all.find((d) => d.slug.includes(idOrSlug));
92
+ if (!match) match = all.find((d) => d.topic.toLowerCase().includes(idOrSlug.toLowerCase()));
93
+ if (!match) {
94
+ const err = new Error(`No decision matching: ${idOrSlug}`);
95
+ err.notFound = true;
96
+ throw err;
97
+ }
98
+ return match;
99
+ }
100
+
101
+ /**
102
+ * Substring search across topic and reason fields.
103
+ */
104
+ export async function searchDecisions(query, { cwd = process.cwd() } = {}) {
105
+ if (!query) throw new Error('Usage: claudenv decisions search <query>');
106
+ const q = query.toLowerCase();
107
+ const all = await listDecisions({ cwd });
108
+ return all.filter(
109
+ (d) =>
110
+ d.topic.toLowerCase().includes(q) ||
111
+ d.reason.toLowerCase().includes(q) ||
112
+ d.chose.toLowerCase().includes(q)
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Move a decision file to `<scope-dir>/archive/`. Preserves frontmatter.
118
+ */
119
+ export async function archiveDecision(idOrSlug, { cwd = process.cwd() } = {}) {
120
+ const target = await showDecision(idOrSlug, { cwd });
121
+ const archiveDir = join(dirname(target.file), 'archive');
122
+ await mkdir(archiveDir, { recursive: true });
123
+ const dest = join(archiveDir, basename(target.file));
124
+ await rename(target.file, dest);
125
+ return { from: target.file, to: dest };
126
+ }
127
+
128
+ /**
129
+ * Format a list for terminal output.
130
+ */
131
+ export function formatDecisionList(decisions) {
132
+ if (decisions.length === 0) return 'No decisions yet.';
133
+ const lines = [];
134
+ for (const d of decisions) {
135
+ const date = d.date ? d.date.slice(0, 10) : ' ';
136
+ const scope = d.scope === 'project' ? '[project]' : '[global] ';
137
+ const deep = d.deepDive ? ' ⛏' : '';
138
+ lines.push(`${date} ${scope} ${d.topic} → ${d.chose} (${d.slug})${deep}`);
139
+ }
140
+ return lines.join('\n');
141
+ }
142
+
143
+ /**
144
+ * Format one decision for terminal output (full).
145
+ */
146
+ export function formatDecisionDetail(d) {
147
+ const lines = [];
148
+ lines.push(`# ${d.topic}`);
149
+ lines.push('');
150
+ lines.push(`- **slug:** ${d.slug}`);
151
+ lines.push(`- **date:** ${d.date || '(none)'}`);
152
+ lines.push(`- **scope:** ${d.scope}`);
153
+ lines.push(`- **chose:** ${d.chose}`);
154
+ lines.push(`- **reason:** ${d.reason}`);
155
+ if (Array.isArray(d.alternatives) && d.alternatives.length) {
156
+ lines.push(`- **alternatives:** ${d.alternatives.join(', ')}`);
157
+ }
158
+ lines.push(`- **deep dive done:** ${d.deepDive ? 'yes' : 'no'}`);
159
+ lines.push(`- **file:** ${d.file}`);
160
+ return lines.join('\n');
161
+ }