@sbains2/lifeos 0.2.0 → 0.2.2

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/bin/lifeos.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from '../src/index.js';
3
3
  import { runDoctorCommand } from '../src/commands/doctor.js';
4
+ import { runBrainCommand } from '../src/commands/brain.js';
4
5
 
5
6
  // Parse argv: optional subcommand + positional target dir + flags
6
7
  // Usage:
@@ -24,10 +25,24 @@ Wizard options:
24
25
  defaults everywhere. Target install time: < 60 seconds.
25
26
 
26
27
  Doctor options:
28
+ --fix Apply deterministic fixes (mkdir folders, copy missing
29
+ council files, generate writing_style stub, generate
30
+ .env.example with help URLs). Never touches secrets or
31
+ shell rc. Re-runs doctor after to show before/after.
27
32
  --json Emit machine-readable JSON (for CI / scripting)
28
33
  --quiet Suppress ✓ lines, only show warnings and errors
29
34
  --strict Exit code 1 even on warnings (default: warnings are 0)
30
35
 
36
+ Brain options:
37
+ --dry-run Show what would run without invoking graphify
38
+ --json Emit machine-readable JSON (for CI / scripting)
39
+
40
+ Note on brain: lifeos brain shells out to graphify (https://github.com/
41
+ safishamsi/graphify), an OSS knowledge-graph tool. Install once with
42
+ "pip install graphifyy && graphify install". Code parsing is 100% local;
43
+ semantic extraction uses your existing AI-assistant session (no separate
44
+ API key, no incremental cost).
45
+
31
46
  Arguments:
32
47
  target-dir Directory to scaffold into / verify (default: current working dir)
33
48
 
@@ -53,11 +68,19 @@ async function main() {
53
68
  json: flags.has('--json'),
54
69
  quiet: flags.has('--quiet'),
55
70
  strict: flags.has('--strict'),
71
+ fix: flags.has('--fix'),
72
+ });
73
+ process.exit(exitCode);
74
+ }
75
+ if (subcommand === 'brain') {
76
+ const exitCode = await runBrainCommand(targetDir, {
77
+ dryRun: flags.has('--dry-run'),
78
+ json: flags.has('--json'),
56
79
  });
57
80
  process.exit(exitCode);
58
81
  }
59
- if (subcommand === 'brain' || subcommand === 'migrate') {
60
- console.error(`\nNot yet implemented: \`lifeos ${subcommand}\`. See docs/COMMANDS.md for the design.`);
82
+ if (subcommand === 'migrate') {
83
+ console.error(`\nNot yet implemented: \`lifeos migrate\`. See docs/COMMANDS.md for the design.`);
61
84
  process.exit(2);
62
85
  }
63
86
  // Default: run the wizard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sbains2/lifeos",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Scaffold a personal LifeOS — life-quadrant folders, a council of AI agent voices, and curated MCP wiring. Generates a working lifeos.config.json + .claude/agents/council/ in 2-4 minutes (or <60s with --minimal). BETA — early seed release.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brain.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `lifeos brain` — thin wrapper around graphify (https://github.com/safishamsi/graphify).
3
+ *
4
+ * Why graphify (decision rationale):
5
+ * - Open-source, MIT-licensed
6
+ * - Runs entirely on the user's machine: code parsing is 100% local via tree-sitter
7
+ * AST + NetworkX + Leiden clustering
8
+ * - For semantic content (docs, images, PDFs), uses the user's EXISTING AI-assistant
9
+ * session — Claude Code, Cursor, Codex, etc. — via credentials they've already
10
+ * configured. No separate API key, no incremental cost beyond their existing
11
+ * subscription.
12
+ * - Cost to LifeOS project: $0 (we don't host or proxy anything)
13
+ * - Cost to friends installing LifeOS: $0 incremental (their Claude Code subscription
14
+ * is already paying for any LLM calls graphify makes)
15
+ *
16
+ * This module is a thin shell-out wrapper. It does not re-implement graphify;
17
+ * it just invokes it with the right paths from lifeos.config.json.
18
+ */
19
+
20
+ import { execSync, spawnSync } from 'node:child_process';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join, isAbsolute } from 'node:path';
23
+
24
+ /**
25
+ * Check if graphify is installed and available in PATH.
26
+ * Pure side-effect-free check. Returns boolean.
27
+ */
28
+ export function isGraphifyInstalled() {
29
+ try {
30
+ execSync('command -v graphify', { stdio: 'ignore' });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Determine what `lifeos brain` would do for a given scaffold, without executing.
39
+ * Returns { ok, paths, cwd, outputHint } on success or { ok: false, error } on failure.
40
+ */
41
+ export function planBrainRun(targetDir) {
42
+ const configPath = join(targetDir, 'lifeos.config.json');
43
+ if (!existsSync(configPath)) {
44
+ return { ok: false, error: `lifeos.config.json not found at ${configPath}` };
45
+ }
46
+ let config;
47
+ try {
48
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
49
+ } catch (err) {
50
+ return { ok: false, error: `lifeos.config.json parse failed: ${err.message}` };
51
+ }
52
+
53
+ // Prefer brain.graphify.paths (explicit config); fall back to active quadrant paths
54
+ const configuredPaths = config.brain?.graphify?.paths;
55
+ const activeQuadrantPaths = (config.quadrants ?? []).filter((q) => q.active).map((q) => q.path);
56
+ const paths = configuredPaths && configuredPaths.length > 0 ? configuredPaths : activeQuadrantPaths;
57
+
58
+ if (paths.length === 0) {
59
+ return {
60
+ ok: false,
61
+ error:
62
+ 'No paths to index. Add at least one active quadrant to lifeos.config.json or set brain.graphify.paths explicitly.',
63
+ };
64
+ }
65
+
66
+ // Resolve relative paths against targetDir for display + check existence (warn, not error)
67
+ const resolvedPaths = paths.map((p) => (isAbsolute(p) ? p : join(targetDir, p)));
68
+ const missingPaths = resolvedPaths.filter((p) => !existsSync(p));
69
+
70
+ return {
71
+ ok: true,
72
+ paths,
73
+ resolvedPaths,
74
+ missingPaths,
75
+ cwd: targetDir,
76
+ outputHint: config.brain?.graphify?.output ?? './graphify-out',
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Invoke graphify with the planned paths. Inherits stdio so the user sees graphify's
82
+ * own output stream. Returns { ok, exitCode, paths, outputDir } or { ok: false, error }.
83
+ *
84
+ * Does NOT auto-install graphify (too invasive — pip install requires user consent).
85
+ * If graphify is missing, returns ok:false with install instructions.
86
+ */
87
+ export function runBrain(targetDir, options = {}) {
88
+ const { dryRun = false } = options;
89
+
90
+ const plan = planBrainRun(targetDir);
91
+ if (!plan.ok) return plan;
92
+
93
+ if (!isGraphifyInstalled()) {
94
+ return {
95
+ ok: false,
96
+ error: 'graphify not installed',
97
+ installCommand: 'pip install graphifyy && graphify install',
98
+ installUrl: 'https://github.com/safishamsi/graphify',
99
+ };
100
+ }
101
+
102
+ if (dryRun) {
103
+ return {
104
+ ok: true,
105
+ dryRun: true,
106
+ command: 'graphify',
107
+ args: plan.paths,
108
+ cwd: plan.cwd,
109
+ outputDir: join(plan.cwd, 'graphify-out'),
110
+ };
111
+ }
112
+
113
+ // Shell out to graphify; let it write to its default `graphify-out/` in the
114
+ // working directory. We deliberately don't try to redirect output — graphify's
115
+ // CLI surface evolves and we don't want to fight it. Output location is
116
+ // surfaced to the user in our wrapper's "next steps" message.
117
+ const result = spawnSync('graphify', plan.paths, {
118
+ cwd: plan.cwd,
119
+ stdio: 'inherit',
120
+ encoding: 'utf8',
121
+ });
122
+
123
+ return {
124
+ ok: result.status === 0,
125
+ exitCode: result.status,
126
+ paths: plan.paths,
127
+ outputDir: join(plan.cwd, 'graphify-out'),
128
+ };
129
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `lifeos brain` subcommand orchestrator.
3
+ *
4
+ * Thin wrapper around src/brain.js — handles flag parsing, output formatting,
5
+ * and exit codes. The actual graphify invocation is in brain.js.
6
+ *
7
+ * Flags:
8
+ * --dry-run Show what would run without invoking graphify
9
+ * (does NOT require graphify to be installed — useful for previewing)
10
+ * --json Emit machine-readable JSON (for CI / scripting)
11
+ */
12
+
13
+ import { runBrain, planBrainRun, isGraphifyInstalled } from '../brain.js';
14
+ import { c } from '../utils/colors.js';
15
+
16
+ export async function runBrainCommand(targetDir, options = {}) {
17
+ const { dryRun = false, json = false } = options;
18
+
19
+ // Step 1: plan first (no external deps — works without graphify)
20
+ const plan = planBrainRun(targetDir);
21
+ if (!plan.ok) {
22
+ if (json) {
23
+ console.log(JSON.stringify(plan, null, 2));
24
+ } else {
25
+ console.log('');
26
+ console.log(c.err(`✗ ${plan.error}`));
27
+ console.log('');
28
+ }
29
+ return 1;
30
+ }
31
+
32
+ // Step 2: if dry-run, print plan and exit (graphify install not required)
33
+ if (dryRun) {
34
+ if (json) {
35
+ console.log(
36
+ JSON.stringify(
37
+ {
38
+ ok: true,
39
+ dryRun: true,
40
+ command: 'graphify',
41
+ args: plan.paths,
42
+ cwd: plan.cwd,
43
+ outputDir: `${plan.cwd}/graphify-out`,
44
+ graphifyInstalled: isGraphifyInstalled(),
45
+ },
46
+ null,
47
+ 2
48
+ )
49
+ );
50
+ } else {
51
+ console.log('');
52
+ console.log(c.bold('lifeos brain --dry-run') + c.dim(' — would invoke:'));
53
+ console.log('');
54
+ console.log(` ${c.cyan('cd')} ${plan.cwd}`);
55
+ console.log(` ${c.cyan('graphify')} ${plan.paths.join(' ')}`);
56
+ console.log('');
57
+ console.log(c.dim(` Output would land in: ${plan.cwd}/graphify-out/`));
58
+ if (plan.missingPaths && plan.missingPaths.length > 0) {
59
+ console.log('');
60
+ console.log(c.warn(` ⚠ ${plan.missingPaths.length} path(s) don't exist — graphify may skip them:`));
61
+ for (const p of plan.missingPaths) {
62
+ console.log(c.dim(` ${p}`));
63
+ }
64
+ }
65
+ if (!isGraphifyInstalled()) {
66
+ console.log('');
67
+ console.log(c.warn(' ⚠ graphify not installed yet — actual run would fail.'));
68
+ console.log(c.dim(' Install: pip install graphifyy && graphify install'));
69
+ }
70
+ console.log('');
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ // Step 3: check graphify installed (only required for real execution)
76
+ if (!isGraphifyInstalled()) {
77
+ if (json) {
78
+ console.log(
79
+ JSON.stringify(
80
+ {
81
+ ok: false,
82
+ error: 'graphify not installed',
83
+ installCommand: 'pip install graphifyy && graphify install',
84
+ installUrl: 'https://github.com/safishamsi/graphify',
85
+ },
86
+ null,
87
+ 2
88
+ )
89
+ );
90
+ } else {
91
+ console.log('');
92
+ console.log(c.bold('lifeos brain') + c.dim(' — knowledge graph via graphify'));
93
+ console.log('');
94
+ console.log(` ${c.err('✗')} graphify not installed`);
95
+ console.log(c.dim(` graphify is the OSS knowledge-graph builder LifeOS uses for the brain.`));
96
+ console.log(c.dim(` It runs entirely on your machine — code parsing is 100% local`));
97
+ console.log(c.dim(` (tree-sitter AST), and semantic extraction goes through your`));
98
+ console.log(c.dim(` existing Claude Code / Cursor / Codex session (no separate API`));
99
+ console.log(c.dim(` key, no incremental cost).`));
100
+ console.log('');
101
+ console.log(` ${c.cyan('→ install: ')}pip install graphifyy && graphify install`);
102
+ console.log(` ${c.cyan('→ docs: ')}https://github.com/safishamsi/graphify`);
103
+ console.log(` ${c.cyan('→ preview: ')}lifeos brain --dry-run ${targetDir}`);
104
+ console.log('');
105
+ }
106
+ return 1;
107
+ }
108
+
109
+ // Step 4: real invocation
110
+ console.log('');
111
+ console.log(c.bold('lifeos brain') + c.dim(` — indexing ${plan.paths.length} path(s) via graphify`));
112
+ console.log(c.dim(` paths: ${plan.paths.join(', ')}`));
113
+ console.log(c.dim(` cwd: ${plan.cwd}`));
114
+ if (plan.missingPaths && plan.missingPaths.length > 0) {
115
+ console.log(c.warn(` ⚠ ${plan.missingPaths.length} path(s) don't exist — graphify may skip them: ${plan.missingPaths.join(', ')}`));
116
+ }
117
+ console.log('');
118
+ console.log(c.dim('--- graphify output below ---'));
119
+ console.log('');
120
+
121
+ const result = runBrain(targetDir, { dryRun: false });
122
+
123
+ console.log('');
124
+ if (result.ok) {
125
+ if (json) {
126
+ console.log(JSON.stringify({ ok: true, paths: result.paths, outputDir: result.outputDir }, null, 2));
127
+ } else {
128
+ console.log(c.bold(c.ok('✓ brain built')));
129
+ console.log(c.dim(` Output: ${result.outputDir}`));
130
+ console.log(c.dim(` View graph: open ${result.outputDir}/graph.html`));
131
+ console.log(c.dim(` Read summary: cat ${result.outputDir}/GRAPH_REPORT.md`));
132
+ console.log(c.dim(` Query graph: graphify query "your question"`));
133
+ console.log('');
134
+ }
135
+ return 0;
136
+ } else {
137
+ if (json) {
138
+ console.log(JSON.stringify({ ok: false, exitCode: result.exitCode }, null, 2));
139
+ } else {
140
+ console.log(c.err(`✗ graphify exited with code ${result.exitCode}`));
141
+ console.log(c.dim(' Check graphify output above for details. Common issues:'));
142
+ console.log(c.dim(' - Paths not readable / outside scope'));
143
+ console.log(c.dim(' - graphify install incomplete (run `graphify install` again)'));
144
+ console.log(c.dim(' - Underlying AI assistant session not configured'));
145
+ }
146
+ return result.exitCode || 1;
147
+ }
148
+ }
@@ -11,25 +11,79 @@
11
11
  */
12
12
 
13
13
  import { runDoctor } from '../doctor.js';
14
+ import { applyDoctorFixes } from '../remedy.js';
14
15
  import { c } from '../utils/colors.js';
15
16
 
16
17
  export async function runDoctorCommand(targetDir, options = {}) {
17
- const { json = false, quiet = false, strict = false } = options;
18
+ const { json = false, quiet = false, strict = false, fix = false } = options;
18
19
 
19
20
  const result = runDoctor(targetDir);
20
21
 
21
- if (json) {
22
+ if (json && !fix) {
22
23
  console.log(JSON.stringify(result, null, 2));
23
- } else {
24
+ if (result.summary.error > 0) return 1;
25
+ if (strict && result.summary.warn > 0) return 1;
26
+ return 0;
27
+ }
28
+
29
+ if (!fix) {
24
30
  printHumanReport(result, { quiet });
31
+ if (result.summary.error > 0) return 1;
32
+ if (strict && result.summary.warn > 0) return 1;
33
+ return 0;
34
+ }
35
+
36
+ // --fix mode: print initial state, apply remedies, re-run doctor, print final state
37
+ console.log('');
38
+ console.log(c.bold('lifeos doctor --fix') + c.dim(' — initial state'));
39
+ console.log('');
40
+ printSummaryLine(result.summary);
41
+
42
+ const remedy = applyDoctorFixes(targetDir);
43
+ console.log('');
44
+ console.log(c.bold('Applying deterministic fixes...'));
45
+ if (remedy.applied.length === 0) {
46
+ console.log(c.dim(` ${c.dot} nothing to auto-fix (issues remaining need user input — see suggestions below)`));
47
+ } else {
48
+ for (const fix of remedy.applied) {
49
+ console.log(` ${c.check} ${fix.id}: ${fix.message}`);
50
+ }
51
+ }
52
+ if (remedy.skipped.length > 0) {
53
+ console.log(c.dim(` Skipped (need manual action):`));
54
+ for (const s of remedy.skipped) {
55
+ console.log(c.dim(` ${c.dot} ${s.id}: ${s.reason}`));
56
+ }
57
+ }
58
+
59
+ // Re-run doctor to show new state
60
+ console.log('');
61
+ console.log(c.bold('Re-running doctor...'));
62
+ console.log('');
63
+ const after = runDoctor(targetDir);
64
+ printHumanReport(after, { quiet });
65
+
66
+ if (json) {
67
+ console.log('');
68
+ console.log(c.dim('--json + --fix combined: emitting after-state JSON'));
69
+ console.log(JSON.stringify(after, null, 2));
25
70
  }
26
71
 
27
- // Exit code: 1 if any error; 1 if any warning AND strict mode; else 0
28
- if (result.summary.error > 0) return 1;
29
- if (strict && result.summary.warn > 0) return 1;
72
+ if (after.summary.error > 0) return 1;
73
+ if (strict && after.summary.warn > 0) return 1;
30
74
  return 0;
31
75
  }
32
76
 
77
+ function printSummaryLine(summary) {
78
+ const { ok, warn, error } = summary;
79
+ const parts = [
80
+ `${c.ok(`${ok} ok`)}`,
81
+ warn > 0 ? c.warn(`${warn} warning${warn === 1 ? '' : 's'}`) : null,
82
+ error > 0 ? c.err(`${error} error${error === 1 ? '' : 's'}`) : null,
83
+ ].filter(Boolean);
84
+ console.log(' ' + parts.join(', '));
85
+ }
86
+
33
87
  function printHumanReport(result, { quiet }) {
34
88
  console.log('');
35
89
  console.log(c.bold('lifeos doctor') + c.dim(' — checking your scaffold'));
package/src/doctor.js CHANGED
@@ -253,7 +253,33 @@ export function runDoctor(targetDir) {
253
253
  }
254
254
  }
255
255
 
256
- // Check 9: MCP runtimes available (npx / uvx / docker)
256
+ // Check 9: graphify installed (only if brain is enabled — graphify powers `lifeos brain`)
257
+ if (config.brain?.graphify?.enabled) {
258
+ let graphifyInstalled = false;
259
+ try {
260
+ execSync('command -v graphify', { stdio: 'ignore' });
261
+ graphifyInstalled = true;
262
+ } catch {
263
+ /* not installed */
264
+ }
265
+ if (graphifyInstalled) {
266
+ checks.push({
267
+ id: 'graphify_installed',
268
+ name: 'graphify installed (powers `lifeos brain`)',
269
+ status: 'ok',
270
+ });
271
+ } else {
272
+ checks.push({
273
+ id: 'graphify_installed',
274
+ name: 'graphify installed (powers `lifeos brain`)',
275
+ status: 'warn',
276
+ message: 'graphify CLI not in PATH — brain command will fail',
277
+ fix: 'Install (no API key needed; uses your existing Claude Code session):\n pip install graphifyy && graphify install\n docs: https://github.com/safishamsi/graphify',
278
+ });
279
+ }
280
+ }
281
+
282
+ // Check 10: MCP runtimes available (npx / uvx / docker)
257
283
  const wiredRuntimes = new Set();
258
284
  if (registry) {
259
285
  const byId = new Map(registry.servers.map((s) => [s.id, s]));
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ export async function run(targetDir, options = {}) {
26
26
  const { minimal = false } = options;
27
27
 
28
28
  console.log('');
29
- console.log(c.bold('lifeos') + c.dim(' v0.2.0 — scaffold a personal LifeOS'));
29
+ console.log(c.bold('lifeos') + c.dim(' v0.2.2 — scaffold a personal LifeOS'));
30
30
  console.log(c.dim(`Target directory: ${targetDir}`));
31
31
  if (minimal) {
32
32
  console.log(c.dim('Mode: --minimal (speed-optimized; using template defaults for quadrants, council, MCPs)'));
package/src/remedy.js ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * `lifeos doctor --fix` — deterministic remediation for an unhealthy scaffold.
3
+ *
4
+ * Trust posture:
5
+ * - NEVER touches user secrets (tokens, OAuth credentials)
6
+ * - NEVER edits the user's shell rc (~/.zshrc, ~/.bashrc)
7
+ * - NEVER overwrites existing files without backup
8
+ * - ONLY applies fixes for checks where the right action is unambiguous and
9
+ * doesn't require user input
10
+ *
11
+ * What it can fix automatically:
12
+ * - Missing quadrant folders → mkdir -p (with placeholder README)
13
+ * - Missing council files → copy from bundled templates
14
+ * - Missing writing_style.md (when path is set in config) → create stub
15
+ * - Missing .env.example for required auth env vars → generate with help-URL
16
+ * comments per env var. User sources it as .env.local after filling in
17
+ * values.
18
+ *
19
+ * What it CANNOT fix automatically (must be user-driven):
20
+ * - Missing env var values (we'd need the secret)
21
+ * - Schema version mismatch (needs `lifeos migrate` — not yet implemented)
22
+ * - Empty brain index (needs `lifeos brain` — not yet implemented)
23
+ * - Missing MCP runtimes (npx / docker / uvx — user must install)
24
+ *
25
+ * Each fix returns { id, applied: boolean, action: string, message: string }.
26
+ * Caller decides how to render.
27
+ */
28
+
29
+ import { existsSync, mkdirSync, writeFileSync, copyFileSync, readFileSync } from 'node:fs';
30
+ import { join, dirname, isAbsolute } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { loadRegistry } from './templates-loader.js';
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const TEMPLATES_COUNCIL = join(__dirname, '..', 'templates', 'council');
36
+
37
+ /**
38
+ * Apply all deterministic fixes to a scaffold. Reads the same lifeos.config.json
39
+ * doctor reads. Returns { applied: [], skipped: [], summary }.
40
+ */
41
+ export function applyDoctorFixes(targetDir, options = {}) {
42
+ const { allowWritingStyleStub = true, allowEnvExample = true } = options;
43
+
44
+ const configPath = join(targetDir, 'lifeos.config.json');
45
+ if (!existsSync(configPath)) {
46
+ return {
47
+ applied: [],
48
+ skipped: [{ id: 'config', reason: 'lifeos.config.json not found — nothing to remediate' }],
49
+ summary: { applied: 0, skipped: 1 },
50
+ };
51
+ }
52
+ let config;
53
+ try {
54
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
55
+ } catch (err) {
56
+ return {
57
+ applied: [],
58
+ skipped: [{ id: 'config', reason: `JSON parse failed: ${err.message} — fix manually first` }],
59
+ summary: { applied: 0, skipped: 1 },
60
+ };
61
+ }
62
+
63
+ const applied = [];
64
+ const skipped = [];
65
+
66
+ // Fix 1: missing quadrant folders
67
+ for (const q of config.quadrants ?? []) {
68
+ if (!q.active) continue;
69
+ const fullPath = isAbsolute(q.path) ? q.path : join(targetDir, q.path);
70
+ if (existsSync(fullPath)) continue;
71
+ try {
72
+ mkdirSync(fullPath, { recursive: true });
73
+ const readme = join(fullPath, 'README.md');
74
+ if (!existsSync(readme)) {
75
+ writeFileSync(readme, quadrantReadme(q), 'utf8');
76
+ }
77
+ applied.push({
78
+ id: `quadrant_folder:${q.id}`,
79
+ action: `mkdir`,
80
+ message: `Created ${q.path}/ + placeholder README`,
81
+ });
82
+ } catch (err) {
83
+ skipped.push({ id: `quadrant_folder:${q.id}`, reason: `mkdir failed: ${err.message}` });
84
+ }
85
+ }
86
+
87
+ // Fix 2: missing council files
88
+ const councilDir = join(targetDir, '.claude', 'agents', 'council');
89
+ for (const member of config.council ?? []) {
90
+ if (!member.system_prompt_path) continue;
91
+ const filename = member.system_prompt_path.split('/').pop();
92
+ const installedPath = join(councilDir, filename);
93
+ if (existsSync(installedPath)) continue;
94
+ const templatePath = join(TEMPLATES_COUNCIL, filename);
95
+ if (!existsSync(templatePath)) {
96
+ skipped.push({
97
+ id: `council_file:${member.id}`,
98
+ reason: `bundled template missing for ${filename} — file may be custom; not auto-fixable`,
99
+ });
100
+ continue;
101
+ }
102
+ try {
103
+ mkdirSync(councilDir, { recursive: true });
104
+ copyFileSync(templatePath, installedPath);
105
+ applied.push({
106
+ id: `council_file:${member.id}`,
107
+ action: 'copy',
108
+ message: `Copied ${filename} from bundled templates`,
109
+ });
110
+ } catch (err) {
111
+ skipped.push({ id: `council_file:${member.id}`, reason: `copy failed: ${err.message}` });
112
+ }
113
+ }
114
+
115
+ // Fix 3: writing_style.md stub if path is set + file missing
116
+ if (allowWritingStyleStub && config.writing_style_path) {
117
+ const stylePath = isAbsolute(config.writing_style_path)
118
+ ? config.writing_style_path
119
+ : join(targetDir, config.writing_style_path);
120
+ if (!existsSync(stylePath)) {
121
+ try {
122
+ mkdirSync(dirname(stylePath), { recursive: true });
123
+ writeFileSync(stylePath, writingStyleStub(), 'utf8');
124
+ applied.push({
125
+ id: 'writing_style',
126
+ action: 'stub',
127
+ message: `Wrote stub at ${config.writing_style_path} — replace with a real sample of your writing`,
128
+ });
129
+ } catch (err) {
130
+ skipped.push({ id: 'writing_style', reason: `write failed: ${err.message}` });
131
+ }
132
+ }
133
+ }
134
+
135
+ // Fix 4: .env.example for required env vars
136
+ if (allowEnvExample) {
137
+ let registry = null;
138
+ try {
139
+ registry = loadRegistry();
140
+ } catch {
141
+ /* skip env example if registry unavailable */
142
+ }
143
+ if (registry) {
144
+ const envEntries = collectEnvEntries(config, registry);
145
+ if (envEntries.length > 0) {
146
+ const envExamplePath = join(targetDir, '.env.example');
147
+ const wasExisting = existsSync(envExamplePath);
148
+ try {
149
+ // Don't overwrite if user already customized — just notify
150
+ if (wasExisting) {
151
+ skipped.push({
152
+ id: 'env_example',
153
+ reason: `.env.example already exists — not overwriting (review and merge manually if needed)`,
154
+ });
155
+ } else {
156
+ writeFileSync(envExamplePath, envExampleContent(envEntries), 'utf8');
157
+ applied.push({
158
+ id: 'env_example',
159
+ action: 'generate',
160
+ message: `Wrote .env.example with ${envEntries.length} env var stub(s) + help URLs. Copy to .env.local, fill in values, then \`source .env.local\` before launching Claude.`,
161
+ });
162
+ }
163
+ } catch (err) {
164
+ skipped.push({ id: 'env_example', reason: `write failed: ${err.message}` });
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return {
171
+ applied,
172
+ skipped,
173
+ summary: { applied: applied.length, skipped: skipped.length },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Collect env var entries needed by wired MCPs.
179
+ * Returns [{ env_var, mcp_id, mcp_name, help_url, scope_hint }].
180
+ */
181
+ function collectEnvEntries(config, registry) {
182
+ const byId = new Map(registry.servers.map((s) => [s.id, s]));
183
+ const entries = [];
184
+ const seen = new Set();
185
+ for (const m of config.mcp_servers ?? []) {
186
+ const entry = byId.get(m.id);
187
+ if (!entry?.auth?.env_var) continue;
188
+ if (seen.has(entry.auth.env_var)) continue;
189
+ seen.add(entry.auth.env_var);
190
+ entries.push({
191
+ env_var: entry.auth.env_var,
192
+ mcp_id: entry.id,
193
+ mcp_name: entry.name,
194
+ help_url: entry.auth.help_url,
195
+ scope_hint: entry.auth.scope_hint,
196
+ });
197
+ }
198
+ return entries;
199
+ }
200
+
201
+ function envExampleContent(entries) {
202
+ const lines = [
203
+ '# LifeOS — wired MCP environment variables',
204
+ '#',
205
+ '# Generated by `lifeos doctor --fix`. To use:',
206
+ '# 1. Copy this file to .env.local in the same directory',
207
+ '# 2. Fill in each value from the help URLs below',
208
+ '# 3. Source it before launching Claude Code:',
209
+ '# source .env.local',
210
+ '# or add a permanent `set -a; source .env.local; set +a` to your shell rc.',
211
+ '#',
212
+ '# Trust note: .env.local should be gitignored (LifeOS scaffold .gitignore covers this).',
213
+ '# LifeOS itself never reads any value in this file — it\'s yours, in your shell.',
214
+ '',
215
+ ];
216
+
217
+ for (const e of entries) {
218
+ lines.push(`# ${e.mcp_name}`);
219
+ if (e.help_url) lines.push(`# Get one: ${e.help_url}`);
220
+ if (e.scope_hint) {
221
+ const wrapped = wrapText(`# Scope: ${e.scope_hint}`, 100);
222
+ lines.push(...wrapped);
223
+ }
224
+ lines.push(`export ${e.env_var}=`);
225
+ lines.push('');
226
+ }
227
+
228
+ return lines.join('\n');
229
+ }
230
+
231
+ function wrapText(text, maxWidth) {
232
+ const lines = [];
233
+ const prefix = text.startsWith('#') ? '# ' : '';
234
+ const content = text.startsWith('#') ? text.slice(2) : text;
235
+ const words = content.split(/\s+/);
236
+ let current = '';
237
+ for (const word of words) {
238
+ if (current.length + word.length + 1 > maxWidth - prefix.length) {
239
+ if (current) lines.push(prefix + current);
240
+ current = word;
241
+ } else {
242
+ current = current ? `${current} ${word}` : word;
243
+ }
244
+ }
245
+ if (current) lines.push(prefix + current);
246
+ return lines;
247
+ }
248
+
249
+ function quadrantReadme(q) {
250
+ return `# ${q.name}
251
+
252
+ ${q.description ?? ''}
253
+
254
+ This folder is the home of your **${q.id}** quadrant. The LifeOS council members assigned to it will read files here as context when convened.
255
+
256
+ ## Council members assigned
257
+
258
+ ${(q.council_member_ids ?? []).map((id) => `- \`${id}\``).join('\n') || '_none_'}
259
+
260
+ ## MCP servers wired
261
+
262
+ ${(q.mcp_server_ids ?? []).map((id) => `- \`${id}\``).join('\n') || '_none_'}
263
+
264
+ ## Priority
265
+
266
+ \`${q.priority}\` — change in \`lifeos.config.json\` to adjust how much airtime this quadrant gets in cross-quadrant convenings.
267
+ `;
268
+ }
269
+
270
+ function writingStyleStub() {
271
+ return `# Writing style
272
+
273
+ > This file anchors your voice for council members that produce written output (Writing Critic, Editor, etc.).
274
+ > Replace this placeholder with a real sample of your writing — an email, an essay paragraph, a Slack message you're proud of.
275
+ > The council reads this verbatim and tries to match the register. Be specific.
276
+
277
+ ## Style notes
278
+
279
+ - _Tone:_ [e.g., formal-analytical, conversational, direct, hedged]
280
+ - _Sentence length preference:_ [e.g., short and punchy / long and layered]
281
+ - _Vocabulary preferences:_ [e.g., academic, plain-spoken, jargon-heavy]
282
+ - _Avoid:_ [e.g., overly casual contractions, filler phrases, marketing-speak]
283
+
284
+ ## Sample (replace this entire section with your own)
285
+
286
+ [Paste 200-500 words of your own writing here. Anything you wrote and stand behind.]
287
+ `;
288
+ }