@sbains2/lifeos 0.1.3
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/LICENSE +21 -0
- package/README.md +122 -0
- package/bin/lifeos.js +46 -0
- package/package.json +54 -0
- package/src/cleanup.js +28 -0
- package/src/index.js +131 -0
- package/src/scaffold.js +147 -0
- package/src/templates-loader.js +34 -0
- package/src/utils/colors.js +14 -0
- package/src/utils/timer.js +12 -0
- package/src/validate.js +113 -0
- package/src/wizard/council.js +45 -0
- package/src/wizard/foci.js +71 -0
- package/src/wizard/generate.js +59 -0
- package/src/wizard/mcps.js +57 -0
- package/src/wizard/persona.js +17 -0
- package/src/wizard/profile.js +27 -0
- package/src/wizard/quadrants.js +124 -0
- package/templates/README.md +86 -0
- package/templates/SCHEMA.md +164 -0
- package/templates/council/academic_advisor.md +22 -0
- package/templates/council/career_coach.md +21 -0
- package/templates/council/devils_advocate.md +20 -0
- package/templates/council/editor.md +22 -0
- package/templates/council/network_builder.md +24 -0
- package/templates/council/performance_coach.md +20 -0
- package/templates/council/research_director.md +21 -0
- package/templates/council/tech_lead.md +22 -0
- package/templates/council/time_manager.md +20 -0
- package/templates/council/writing_critic.md +22 -0
- package/templates/mcps/registry.json +209 -0
- package/templates/personas/early-career-ic.json +127 -0
- package/templates/personas/grad-student.json +136 -0
- package/templates/personas/solo-researcher.json +114 -0
package/src/validate.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, accessSync, constants } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listCouncilFiles, loadRegistry } from './templates-loader.js';
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_VERSION = '0.2';
|
|
6
|
+
const PRIORITY_ENUM = new Set(['hot', 'warm', 'lukewarm', 'cold']);
|
|
7
|
+
const HORIZON_ENUM = new Set(['now', 'quarter', 'year', 'arc']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run all validation checks against a config object.
|
|
11
|
+
* Returns { ok: boolean, errors: string[], warnings: string[] }.
|
|
12
|
+
* Never throws — caller decides what to do with the results.
|
|
13
|
+
*/
|
|
14
|
+
export function validate(config, { targetDir }) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const warnings = [];
|
|
17
|
+
|
|
18
|
+
// Check 7 (run first — gates everything else): version match
|
|
19
|
+
if (config.version !== SUPPORTED_VERSION) {
|
|
20
|
+
errors.push(
|
|
21
|
+
`Schema version mismatch: config has ${config.version}, CLI was built for ${SUPPORTED_VERSION}. ` +
|
|
22
|
+
`Future versions will offer 'lifeos migrate' to upgrade old configs; for now, regenerate.`
|
|
23
|
+
);
|
|
24
|
+
return { ok: false, errors, warnings };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check 5: foci has at least one entry
|
|
28
|
+
const foci = config.user?.context?.foci;
|
|
29
|
+
if (!Array.isArray(foci) || foci.length === 0) {
|
|
30
|
+
errors.push('user.context.foci must have at least one entry — what are you working toward?');
|
|
31
|
+
} else {
|
|
32
|
+
foci.forEach((f, i) => {
|
|
33
|
+
if (!HORIZON_ENUM.has(f.horizon)) {
|
|
34
|
+
errors.push(`foci[${i}].horizon must be one of: ${[...HORIZON_ENUM].join(', ')} (got '${f.horizon}')`);
|
|
35
|
+
}
|
|
36
|
+
if (!PRIORITY_ENUM.has(f.priority)) {
|
|
37
|
+
errors.push(`foci[${i}].priority must be one of: ${[...PRIORITY_ENUM].join(', ')} (got '${f.priority}')`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build lookup sets for cross-reference checks
|
|
43
|
+
const councilIds = new Set((config.council ?? []).map((c) => c.id));
|
|
44
|
+
const mcpIds = new Set((config.mcp_servers ?? []).map((m) => m.id));
|
|
45
|
+
|
|
46
|
+
// Check 1: council_member_ids resolve
|
|
47
|
+
// Check 2: mcp_server_ids resolve
|
|
48
|
+
for (const q of config.quadrants ?? []) {
|
|
49
|
+
if (!PRIORITY_ENUM.has(q.priority)) {
|
|
50
|
+
errors.push(`quadrant '${q.id}'.priority must be one of: ${[...PRIORITY_ENUM].join(', ')} (got '${q.priority}')`);
|
|
51
|
+
}
|
|
52
|
+
for (const cid of q.council_member_ids ?? []) {
|
|
53
|
+
if (!councilIds.has(cid)) {
|
|
54
|
+
errors.push(`quadrant '${q.id}' references council member '${cid}' that is not defined in council[]`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const mid of q.mcp_server_ids ?? []) {
|
|
58
|
+
if (!mcpIds.has(mid)) {
|
|
59
|
+
errors.push(`quadrant '${q.id}' references mcp server '${mid}' that is not in mcp_servers[]`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check 3: mcp_servers entries exist in registry
|
|
65
|
+
let registryIds;
|
|
66
|
+
try {
|
|
67
|
+
registryIds = new Set(loadRegistry().servers.map((s) => s.id));
|
|
68
|
+
for (const m of config.mcp_servers ?? []) {
|
|
69
|
+
if (!registryIds.has(m.id)) {
|
|
70
|
+
warnings.push(
|
|
71
|
+
`mcp server '${m.id}' is not in the bundled registry — install hints won't be auto-generated. ` +
|
|
72
|
+
`Add it manually to .lifeos/INSTALL_MCPS.md after install.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
warnings.push(`Could not load MCP registry for validation: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check 4: council system_prompt_paths point to copyable template files
|
|
81
|
+
const availableCouncilFiles = new Set(listCouncilFiles());
|
|
82
|
+
for (const c of config.council ?? []) {
|
|
83
|
+
if (!PRIORITY_ENUM.has(c.priority)) {
|
|
84
|
+
errors.push(`council '${c.id}'.priority must be one of: ${[...PRIORITY_ENUM].join(', ')} (got '${c.priority}')`);
|
|
85
|
+
}
|
|
86
|
+
const filename = c.system_prompt_path?.split('/').pop();
|
|
87
|
+
if (!filename) {
|
|
88
|
+
errors.push(`council '${c.id}' has no system_prompt_path`);
|
|
89
|
+
} else if (!availableCouncilFiles.has(filename)) {
|
|
90
|
+
errors.push(
|
|
91
|
+
`council '${c.id}' references '${c.system_prompt_path}' but no template file '${filename}' exists in cli/templates/council/`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check 6: quadrant paths writeable from CWD
|
|
97
|
+
for (const q of config.quadrants ?? []) {
|
|
98
|
+
if (!q.active) continue;
|
|
99
|
+
const fullPath = join(targetDir, q.path);
|
|
100
|
+
// Try to access the parent directory; we can mkdir if it doesn't exist yet
|
|
101
|
+
let parent = fullPath;
|
|
102
|
+
while (parent !== '/' && !existsSync(parent)) {
|
|
103
|
+
parent = parent.split('/').slice(0, -1).join('/') || '/';
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
accessSync(parent, constants.W_OK);
|
|
107
|
+
} catch {
|
|
108
|
+
errors.push(`quadrant '${q.id}'.path '${q.path}' resolves to '${fullPath}' — parent directory '${parent}' is not writeable`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
113
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { c } from '../utils/colors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Step 5: take template council, ask "use defaults?". Granular priority adjustment
|
|
6
|
+
* is post-v0.1 — users edit JSON for that.
|
|
7
|
+
*/
|
|
8
|
+
export async function askCouncil(templateCouncil) {
|
|
9
|
+
console.log(c.bold('\nStep 5 of 7 — Council'));
|
|
10
|
+
console.log(c.dim('Your persona suggests this council with these priorities:'));
|
|
11
|
+
for (const m of templateCouncil) {
|
|
12
|
+
console.log(` ${formatPriority(m.priority)} ${m.name.padEnd(22)} ${c.dim(m.voice ?? '')}`);
|
|
13
|
+
}
|
|
14
|
+
console.log('');
|
|
15
|
+
|
|
16
|
+
const useDefaults = await confirm({
|
|
17
|
+
message: 'Use these defaults? (you can edit lifeos.config.json afterward to tune any voice)',
|
|
18
|
+
default: true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// v0.1: only "use defaults" is supported. Granular adjustment is post-v0.1.
|
|
22
|
+
if (!useDefaults) {
|
|
23
|
+
console.log(c.dim(' Granular per-member priority adjustment is post-v0.1 — using defaults; edit lifeos.config.json to tune.'));
|
|
24
|
+
}
|
|
25
|
+
return templateCouncil;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pad to visible width 8 BEFORE colorizing — chalk's ANSI codes throw off
|
|
29
|
+
// .length-based padEnd calls (chalk.dim's escape is shorter than chalk.yellow's,
|
|
30
|
+
// so "cold" would render with more visible space than "warm" if padded after).
|
|
31
|
+
function formatPriority(p) {
|
|
32
|
+
const padded = p.padEnd(8);
|
|
33
|
+
switch (p) {
|
|
34
|
+
case 'hot':
|
|
35
|
+
return c.err(padded);
|
|
36
|
+
case 'warm':
|
|
37
|
+
return c.warn(padded);
|
|
38
|
+
case 'lukewarm':
|
|
39
|
+
return c.cyan(padded);
|
|
40
|
+
case 'cold':
|
|
41
|
+
return c.dim(padded);
|
|
42
|
+
default:
|
|
43
|
+
return padded;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { input, select, confirm } from '@inquirer/prompts';
|
|
2
|
+
import { c } from '../utils/colors.js';
|
|
3
|
+
|
|
4
|
+
const HORIZON_CHOICES = [
|
|
5
|
+
{ name: 'Quarter (next ~3 months)', value: 'quarter' },
|
|
6
|
+
{ name: 'Year (next 1-2 years)', value: 'year' },
|
|
7
|
+
{ name: 'Now (this week)', value: 'now' },
|
|
8
|
+
{ name: 'Arc (multi-year)', value: 'arc' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const PRIORITY_CHOICES = [
|
|
12
|
+
{ name: 'hot — your single most important thing', value: 'hot' },
|
|
13
|
+
{ name: 'warm — actively pursuing', value: 'warm' },
|
|
14
|
+
{ name: 'lukewarm — on the radar', value: 'lukewarm' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export async function askFoci(options = {}) {
|
|
18
|
+
const { minimal = false } = options;
|
|
19
|
+
console.log(c.bold('\nStep 3 of 7 — Foci'));
|
|
20
|
+
console.log(c.dim('What are you working toward? You need at least one focus; add more if your life has multiple horizons.'));
|
|
21
|
+
const foci = [];
|
|
22
|
+
|
|
23
|
+
// First focus — required (always asked, even in minimal mode)
|
|
24
|
+
const description1 = await input({
|
|
25
|
+
message: "What's the single most important outcome you're working toward in the next 3 months?",
|
|
26
|
+
});
|
|
27
|
+
const priority1 = minimal
|
|
28
|
+
? 'hot'
|
|
29
|
+
: await select({ message: 'Priority?', choices: PRIORITY_CHOICES, default: 'hot' });
|
|
30
|
+
foci.push({ id: 'primary-quarter', horizon: 'quarter', description: description1, priority: priority1 });
|
|
31
|
+
|
|
32
|
+
if (minimal) {
|
|
33
|
+
console.log(c.dim(' (--minimal: skipping longer-arc focus; you can add later in lifeos.config.json)'));
|
|
34
|
+
return foci;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Second focus — optional, default yes (year-horizon arc)
|
|
38
|
+
// (BUGFIX 2026-04-30: previously combined "Add a longer-arc focus?" with the
|
|
39
|
+
// description prompt in a single confirm message. Users would start typing a
|
|
40
|
+
// description into the y/n prompt and lose those keystrokes. Now split into a
|
|
41
|
+
// clean confirm followed by a clearly-an-input prompt.)
|
|
42
|
+
const wantArc = await confirm({
|
|
43
|
+
message: 'Add a longer-arc focus (next 1-2 years)?',
|
|
44
|
+
default: true,
|
|
45
|
+
});
|
|
46
|
+
if (wantArc) {
|
|
47
|
+
const description2 = await input({
|
|
48
|
+
message: "What's the bigger arc this quarter is a step toward?",
|
|
49
|
+
});
|
|
50
|
+
const priority2 = await select({
|
|
51
|
+
message: 'Priority?',
|
|
52
|
+
choices: PRIORITY_CHOICES,
|
|
53
|
+
default: 'warm',
|
|
54
|
+
});
|
|
55
|
+
foci.push({ id: 'longer-arc', horizon: 'year', description: description2, priority: priority2 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Third+ — optional loop
|
|
59
|
+
let i = 3;
|
|
60
|
+
while (true) {
|
|
61
|
+
const more = await confirm({ message: 'Add another focus?', default: false });
|
|
62
|
+
if (!more) break;
|
|
63
|
+
const description = await input({ message: 'Describe it:' });
|
|
64
|
+
const horizon = await select({ message: 'Horizon?', choices: HORIZON_CHOICES, default: 'quarter' });
|
|
65
|
+
const priority = await select({ message: 'Priority?', choices: PRIORITY_CHOICES, default: 'warm' });
|
|
66
|
+
foci.push({ id: `focus-${i}`, horizon, description, priority });
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return foci;
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { validate } from '../validate.js';
|
|
3
|
+
import { scaffold, printScaffoldReport } from '../scaffold.js';
|
|
4
|
+
import { c } from '../utils/colors.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Step 7: validate the assembled config, refuse to write if invalid, otherwise scaffold.
|
|
8
|
+
* In --minimal mode, skips the "About to scaffold... proceed?" confirmation.
|
|
9
|
+
*/
|
|
10
|
+
export async function validateAndGenerate(config, options) {
|
|
11
|
+
const { targetDir, minimal = false } = options;
|
|
12
|
+
console.log(c.bold('\nStep 7 of 7 — Validate + generate'));
|
|
13
|
+
console.log(c.dim('Validating...'));
|
|
14
|
+
|
|
15
|
+
const result = validate(config, { targetDir });
|
|
16
|
+
|
|
17
|
+
if (result.warnings.length) {
|
|
18
|
+
console.log('');
|
|
19
|
+
for (const w of result.warnings) {
|
|
20
|
+
console.log(` ${c.warn('!')} ${w}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(c.err('Validation failed. Refusing to write.'));
|
|
27
|
+
for (const e of result.errors) {
|
|
28
|
+
console.log(` ${c.cross} ${e}`);
|
|
29
|
+
}
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log(c.dim('No files written. Fix the issues and re-run.'));
|
|
32
|
+
return { ok: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(` ${c.check} All council_member_ids resolve`);
|
|
36
|
+
console.log(` ${c.check} All mcp_server_ids resolve`);
|
|
37
|
+
console.log(` ${c.check} All council system_prompt_paths copyable from templates/`);
|
|
38
|
+
console.log(` ${c.check} All quadrant paths writeable`);
|
|
39
|
+
console.log(` ${c.check} foci has at least one entry`);
|
|
40
|
+
console.log(` ${c.check} Schema version matches`);
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
if (!minimal) {
|
|
44
|
+
const proceed = await confirm({
|
|
45
|
+
message: `About to scaffold into ${targetDir}. Proceed?`,
|
|
46
|
+
default: true,
|
|
47
|
+
});
|
|
48
|
+
if (!proceed) {
|
|
49
|
+
console.log(c.dim('Cancelled. No files written.'));
|
|
50
|
+
return { ok: false };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('');
|
|
55
|
+
console.log(c.bold('Writing:'));
|
|
56
|
+
const created = scaffold(config, { targetDir });
|
|
57
|
+
printScaffoldReport(created);
|
|
58
|
+
return { ok: true, created };
|
|
59
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { checkbox } from '@inquirer/prompts';
|
|
2
|
+
import { loadRegistry } from '../templates-loader.js';
|
|
3
|
+
import { c } from '../utils/colors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Step 6: confirm which of the persona's default MCP servers to wire.
|
|
7
|
+
* Pre-checks the persona's defaults; users uncheck what they don't use.
|
|
8
|
+
*/
|
|
9
|
+
export async function askMcps(templateMcps) {
|
|
10
|
+
console.log(c.bold('\nStep 6 of 7 — Apps & MCPs'));
|
|
11
|
+
let registry = { servers: [] };
|
|
12
|
+
try {
|
|
13
|
+
registry = loadRegistry();
|
|
14
|
+
} catch {
|
|
15
|
+
/* registry not loadable; proceed with template-only metadata */
|
|
16
|
+
}
|
|
17
|
+
const byId = new Map(registry.servers.map((s) => [s.id, s]));
|
|
18
|
+
|
|
19
|
+
const choices = templateMcps.map((m) => {
|
|
20
|
+
const meta = byId.get(m.id);
|
|
21
|
+
const desc = meta ? meta.description : '(not in registry)';
|
|
22
|
+
return {
|
|
23
|
+
// `name` is the long row shown during selection (with description)
|
|
24
|
+
name: `${m.id.padEnd(20)} ${c.dim(desc)}`,
|
|
25
|
+
// `short` is shown in Inquirer's "after-answer" echo — keep it tight
|
|
26
|
+
short: m.id,
|
|
27
|
+
value: m.id,
|
|
28
|
+
checked: true,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const selectedIds = await checkbox({
|
|
33
|
+
message: "Which apps do you actually use? (uncheck the ones you don't)",
|
|
34
|
+
choices,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const selectedSet = new Set(selectedIds);
|
|
38
|
+
const wired = templateMcps.filter((m) => selectedSet.has(m.id));
|
|
39
|
+
|
|
40
|
+
// Print our own clean summary directly after Inquirer's echo —
|
|
41
|
+
// even with `short` set, the answer line can still wrap awkwardly on
|
|
42
|
+
// small terminals. This summary is one server per line, easy to scan.
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(c.bold(`Wired MCP servers (${wired.length}):`));
|
|
45
|
+
for (const m of wired) {
|
|
46
|
+
console.log(` ${c.check} ${m.id}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(c.bold('Trust defaults applied:'));
|
|
51
|
+
console.log(` ${c.dot} ${c.dim('Gmail (if wired) is DRAFT-ONLY by default — no auto-send.')}`);
|
|
52
|
+
console.log(` ${c.dot} ${c.dim('All auth tokens stay in your shell env vars — never copied into the config.')}`);
|
|
53
|
+
console.log(` ${c.dot} ${c.dim('Install hints will be written to .lifeos/INSTALL_MCPS.md.')}`);
|
|
54
|
+
console.log('');
|
|
55
|
+
|
|
56
|
+
return wired;
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { c } from '../utils/colors.js';
|
|
3
|
+
|
|
4
|
+
const PERSONA_CHOICES = [
|
|
5
|
+
{ name: 'Grad student', value: 'grad-student', description: 'Coursework + research + job search + leadership roles' },
|
|
6
|
+
{ name: 'Early-career IC (engineer / analyst / PM, years 0-4)', value: 'early-career-ic', description: 'Day job + career growth + side projects' },
|
|
7
|
+
{ name: 'Solo researcher / indie writer / consultant', value: 'solo-researcher', description: 'Active research + publishing + clients' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export async function askPersona() {
|
|
11
|
+
console.log(c.bold('\nStep 1 of 7 — Persona'));
|
|
12
|
+
const persona = await select({
|
|
13
|
+
message: 'Which best describes your current life?',
|
|
14
|
+
choices: PERSONA_CHOICES,
|
|
15
|
+
});
|
|
16
|
+
return persona;
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { input } from '@inquirer/prompts';
|
|
2
|
+
import { c } from '../utils/colors.js';
|
|
3
|
+
|
|
4
|
+
export async function askProfile(options = {}) {
|
|
5
|
+
const { minimal = false } = options;
|
|
6
|
+
console.log(c.bold('\nStep 2 of 7 — Profile'));
|
|
7
|
+
const name = await input({ message: 'Your name?' });
|
|
8
|
+
|
|
9
|
+
if (minimal) {
|
|
10
|
+
console.log(c.dim(' (--minimal: skipping role + affiliations; you can add later in lifeos.config.json)'));
|
|
11
|
+
return { name, context: { role: '', affiliations: [] } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const role = await input({
|
|
15
|
+
message: 'Your current role / title?',
|
|
16
|
+
default: '',
|
|
17
|
+
});
|
|
18
|
+
const affiliationsRaw = await input({
|
|
19
|
+
message: 'Affiliations (schools, employers, communities — comma-separated)?',
|
|
20
|
+
default: '',
|
|
21
|
+
});
|
|
22
|
+
const affiliations = affiliationsRaw
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((s) => s.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
return { name, context: { role, affiliations } };
|
|
27
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { confirm, checkbox } from '@inquirer/prompts';
|
|
2
|
+
import { c } from '../utils/colors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map of off-by-default quadrant id → keywords whose presence in a user's
|
|
6
|
+
* `role` or `affiliations` strongly implies that quadrant applies. Used by
|
|
7
|
+
* Step 4 to auto-flip matching quadrants on so users don't have to re-route
|
|
8
|
+
* through the granular checkbox.
|
|
9
|
+
*
|
|
10
|
+
* Keep substrings narrow enough to avoid false positives (e.g. don't include
|
|
11
|
+
* a bare "lead" — too many unrelated matches like "leading edge").
|
|
12
|
+
*/
|
|
13
|
+
export const KEYWORD_TO_QUADRANT = {
|
|
14
|
+
leadership: [
|
|
15
|
+
'president', 'vice president', 'vp ',
|
|
16
|
+
'captain', 'officer', 'chair', 'chief',
|
|
17
|
+
'head of', 'coordinator', 'treasurer', 'secretary',
|
|
18
|
+
'club lead', 'team lead', 'leadership', 'organizer',
|
|
19
|
+
],
|
|
20
|
+
infra: [
|
|
21
|
+
'infra', 'devops', 'sre', 'platform engineer',
|
|
22
|
+
'tooling', 'automation', 'automations', 'maintainer',
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns { quadrantId: matchedKeyword, ... } for any keywords found in the
|
|
28
|
+
* user's role or affiliations. Pure function — exported for testability.
|
|
29
|
+
*/
|
|
30
|
+
export function detectQuadrantSignals(profile) {
|
|
31
|
+
const text = `${profile?.context?.role ?? ''} ${(profile?.context?.affiliations ?? []).join(' ')}`.toLowerCase();
|
|
32
|
+
const matches = {};
|
|
33
|
+
for (const [quadrantId, keywords] of Object.entries(KEYWORD_TO_QUADRANT)) {
|
|
34
|
+
const hit = keywords.find((k) => text.includes(k));
|
|
35
|
+
if (hit) matches[quadrantId] = hit;
|
|
36
|
+
}
|
|
37
|
+
return matches;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Step 4: take template quadrants, auto-flip any matched by user's profile,
|
|
42
|
+
* ask "use defaults?", optionally edit via checkbox.
|
|
43
|
+
*/
|
|
44
|
+
export async function askQuadrants(templateQuadrants, profile) {
|
|
45
|
+
console.log(c.bold('\nStep 4 of 7 — Quadrants'));
|
|
46
|
+
|
|
47
|
+
// Auto-detect quadrants the user implicitly mentioned in their profile
|
|
48
|
+
const matches = detectQuadrantSignals(profile);
|
|
49
|
+
|
|
50
|
+
// Adjust template: flip matched + previously-off quadrants on, bump cold → lukewarm.
|
|
51
|
+
// Don't touch quadrants that were already active.
|
|
52
|
+
const adjusted = templateQuadrants.map((q) => {
|
|
53
|
+
if (matches[q.id] && !q.active) {
|
|
54
|
+
return { ...q, active: true, priority: q.priority === 'cold' ? 'lukewarm' : q.priority };
|
|
55
|
+
}
|
|
56
|
+
return q;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Surface what was auto-detected so the user sees why a quadrant flipped on
|
|
60
|
+
const newlyActive = Object.keys(matches).filter((id) => {
|
|
61
|
+
const wasActive = templateQuadrants.find((q) => q.id === id)?.active;
|
|
62
|
+
return !wasActive && adjusted.find((q) => q.id === id)?.active;
|
|
63
|
+
});
|
|
64
|
+
if (newlyActive.length > 0) {
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log(c.dim('Auto-detected from your profile:'));
|
|
67
|
+
for (const id of newlyActive) {
|
|
68
|
+
const q = adjusted.find((qq) => qq.id === id);
|
|
69
|
+
console.log(` ${c.cyan('•')} matched ${c.bold(`"${matches[id]}"`)} → suggesting "${q.name}" turned on (uncheck below if not relevant)`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(c.dim('Your persona suggests these life quadrants:'));
|
|
75
|
+
for (const q of adjusted) {
|
|
76
|
+
const status = q.active ? c.ok('[active]') : c.dim('[ off ]');
|
|
77
|
+
console.log(` ${status} ${q.name.padEnd(36)} ${c.dim(q.path.padEnd(24))} priority: ${formatPriority(q.priority)}`);
|
|
78
|
+
}
|
|
79
|
+
console.log('');
|
|
80
|
+
|
|
81
|
+
const useDefaults = await confirm({
|
|
82
|
+
message: 'Use these defaults? (you can edit lifeos.config.json afterward)',
|
|
83
|
+
default: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (useDefaults) {
|
|
87
|
+
return adjusted;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Granular: pick which to activate
|
|
91
|
+
const activeIds = await checkbox({
|
|
92
|
+
message: 'Which quadrants should be active?',
|
|
93
|
+
choices: adjusted.map((q) => ({
|
|
94
|
+
name: `${q.name} ${c.dim(q.path)}`,
|
|
95
|
+
value: q.id,
|
|
96
|
+
checked: q.active,
|
|
97
|
+
})),
|
|
98
|
+
});
|
|
99
|
+
const activeSet = new Set(activeIds);
|
|
100
|
+
return adjusted.map((q) => ({
|
|
101
|
+
...q,
|
|
102
|
+
active: activeSet.has(q.id),
|
|
103
|
+
// If user just turned it on but template had it cold-by-default, bump to warm
|
|
104
|
+
priority: !q.active && activeSet.has(q.id) && q.priority === 'cold' ? 'warm' : q.priority,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pad to visible width 8 BEFORE colorizing — chalk's ANSI escape codes throw
|
|
109
|
+
// off .length-based padEnd alignment otherwise. Same fix as council.js.
|
|
110
|
+
function formatPriority(p) {
|
|
111
|
+
const padded = p.padEnd(8);
|
|
112
|
+
switch (p) {
|
|
113
|
+
case 'hot':
|
|
114
|
+
return c.err(padded);
|
|
115
|
+
case 'warm':
|
|
116
|
+
return c.warn(padded);
|
|
117
|
+
case 'lukewarm':
|
|
118
|
+
return c.cyan(padded);
|
|
119
|
+
case 'cold':
|
|
120
|
+
return c.dim(padded);
|
|
121
|
+
default:
|
|
122
|
+
return padded;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# LifeOS templates — v0.2
|
|
2
|
+
|
|
3
|
+
This directory contains the v0.2 building blocks the CLI scaffold (`npx @sbains2/lifeos`, designed in `_docs/cli-design.md`, not yet implemented) will assemble during onboarding. Until the CLI exists, the templates can be wired up by hand following the manual install below.
|
|
4
|
+
|
|
5
|
+
## Trust defaults
|
|
6
|
+
|
|
7
|
+
LifeOS is designed to be safe-by-default. The single most-cited objection from early user interviews was *privacy and reputation* — users want autonomous agents but are scared of automated outreach blowing up their reputation, or sensitive data leaking to third parties. The defaults below address both. Each can be opted out of explicitly; none are silent.
|
|
8
|
+
|
|
9
|
+
| Default | What it means | How to override |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **Local-first storage** | Your `lifeos.config.json`, council prompts, brain output, and memory all live on your machine. LifeOS itself makes no network calls except optionally fetching the MCP registry from the LifeOS GitHub on first install (cached locally thereafter). | If you opt into the future hosted tier, the per-user data residency commitment is documented separately. |
|
|
12
|
+
| **Draft-only outreach** | The Gmail MCP ships with `config_overrides.send_permission: false`. Council members and automations can *draft* messages but cannot send them — drafts land in your inbox or in a `pending_outreach.md` file for your review. | Set `send_permission: true` on the Gmail MCP in your config. The wizard will warn before doing this. |
|
|
13
|
+
| **Env vars referenced by name only** | Auth tokens (`GITHUB_PERSONAL_ACCESS_TOKEN`, `GMAIL_OAUTH_TOKEN`, etc.) are referenced in the config by *name*, never by value. Token values stay in your shell environment or a secrets manager — they're never copied into the config file, never logged, never sent in telemetry. | n/a — this is non-negotiable in the schema. |
|
|
14
|
+
| **Telemetry is opt-in and anonymous** | If the future CLI ships telemetry, it's opt-in only at install time, anonymous, and limited to install count + wizard completion time. No content, no identifiers, no MCP usage. | Decline the prompt at install time, or never enable in your config. |
|
|
15
|
+
| **Council convenings run against local files** | The brain index (graphify), council prompts, and quadrant content all live in your repo. Convenings read from local; nothing is uploaded. | The hosted tier (post-v1) will offer optional cloud-side convening with explicit per-user encryption — opt-in only. |
|
|
16
|
+
|
|
17
|
+
**Plain-English summary for the README and the Loom:**
|
|
18
|
+
> Your data stays on your machine. Outreach is drafted, never sent. You hit send. LifeOS makes no claims to your tokens or your inbox.
|
|
19
|
+
|
|
20
|
+
If you find a default that doesn't match these commitments, file an issue — that's a bug, not a feature.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## What's new in v0.2
|
|
25
|
+
|
|
26
|
+
- `user.context.foci[]` replaces `current_focus: string` — short-term and long-term focus are now first-class
|
|
27
|
+
- `priority: hot | warm | lukewarm | cold` enum on quadrants, council members, and foci replaces boolean `active`
|
|
28
|
+
- Persona templates ship with **rich defaults** — all common patterns present, optional ones at `cold` or `active: false`. Users trim down rather than imagining what to add.
|
|
29
|
+
- Persona template council rosters now include all 10 council members with appropriate priorities (was 6-7 in v0.1)
|
|
30
|
+
- Optional **Leadership** quadrant added to grad-student template (covers TA coordinator, club officer, RA, etc.)
|
|
31
|
+
- Optional **Tooling & Automations** quadrant added to grad-student and early-career-ic templates
|
|
32
|
+
- `performance_coach` now in all three persona councils (was IC-only in v0.1)
|
|
33
|
+
- `mcp_servers[]` carry id + `config_overrides` only; registry is the source of truth for runtime/auth/install metadata
|
|
34
|
+
- Templates are **generic** — no specific user's data leaks into the templates themselves
|
|
35
|
+
|
|
36
|
+
## Layout
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
templates/
|
|
40
|
+
SCHEMA.md # The lifeos.config.json v0.2 schema spec
|
|
41
|
+
README.md # This file
|
|
42
|
+
personas/
|
|
43
|
+
grad-student.json # Persona-specific config templates — copy one to ./lifeos.config.json
|
|
44
|
+
early-career-ic.json
|
|
45
|
+
solo-researcher.json
|
|
46
|
+
council/
|
|
47
|
+
*.md # System prompts for council members. Persona configs reference these by path.
|
|
48
|
+
mcps/
|
|
49
|
+
registry.json # Curated catalog of MCP servers. The CLI uses this to map app stack → suggested MCPs.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Manual install (until CLI exists)
|
|
53
|
+
|
|
54
|
+
1. Pick a persona from `templates/personas/` and copy it to `./lifeos.config.json` at your repo root.
|
|
55
|
+
2. Edit `user.name`, `user.context.role`, `user.context.affiliations` to match you.
|
|
56
|
+
3. Edit `user.context.foci[]` — replace placeholder descriptions with your actual quarter and year focus. Set priority for each.
|
|
57
|
+
4. Walk `quadrants[]` — for each one:
|
|
58
|
+
- Decide if it applies. Toggle `active: true | false`.
|
|
59
|
+
- Set `priority: hot | warm | lukewarm | cold` for the active ones.
|
|
60
|
+
- Adjust the `path:` to match your folder layout.
|
|
61
|
+
5. Walk `council[]` — adjust each member's `priority` based on your current life. `cold` means disabled.
|
|
62
|
+
6. Trim `mcp_servers[]` to what you actually use. For each one, follow the install hint in `templates/mcps/registry.json` and set the appropriate env var / OAuth token.
|
|
63
|
+
7. Copy `templates/council/*.md` into `./.claude/agents/council/` so the `system_prompt_path` references resolve.
|
|
64
|
+
8. Create the folders for each `active: true` quadrant (`mkdir -p ./Classes ./Research ./JobSearch ...`).
|
|
65
|
+
9. Optionally create `./writing_style.md` to anchor written-output council members; or remove `writing_style_path` from your config if you skip it.
|
|
66
|
+
|
|
67
|
+
Total time for a careful install: ~10 minutes. The CLI's job is to compress this to <2 minutes (see `_docs/cli-design.md`).
|
|
68
|
+
|
|
69
|
+
## What's NOT in v0.2 yet
|
|
70
|
+
|
|
71
|
+
- The `npx @sbains2/lifeos` CLI itself (designed in `_docs/cli-design.md`, not implemented)
|
|
72
|
+
- Runtime executor for `auto_actions` (declared in schema; no executor)
|
|
73
|
+
- Runtime context-tool that consumes `priority` at convening time (specified in SCHEMA.md, not implemented)
|
|
74
|
+
- Graphify daemon mode (only `refresh: "manual"` works)
|
|
75
|
+
- `lifeos doctor` / `lifeos brain` / `lifeos migrate` commands
|
|
76
|
+
- A JSON-Schema file for machine validation (SCHEMA.md is human-readable only)
|
|
77
|
+
- `entities[]` top-level for off-repo things — deferred to v0.3
|
|
78
|
+
- Composable `secondary_personas[]` for hybrid users — deferred
|
|
79
|
+
|
|
80
|
+
## Versioning
|
|
81
|
+
|
|
82
|
+
Files are at `version: "0.2"`. Expect breaking changes through v0.x. v1.0 is the first stability commitment.
|
|
83
|
+
|
|
84
|
+
## Contributing
|
|
85
|
+
|
|
86
|
+
Don't yet — this is single-author scaffolding. Templates will open for community contribution once v0.3+ stabilizes the schema and the CLI ships.
|