my-patina 0.7.0 → 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/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  import * as p from "@clack/prompts";
8
8
  import chalk from "chalk";
9
9
  import { dirname as dirname4, join as join7, resolve } from "path";
10
- import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
10
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
11
11
  import yaml3 from "js-yaml";
12
12
 
13
13
  // src/detect.ts
@@ -23,7 +23,7 @@ function loadProfile(root) {
23
23
  }
24
24
 
25
25
  // src/scaffold.ts
26
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
26
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
27
27
  import { join as join5, dirname as dirname3 } from "path";
28
28
  import yaml2 from "js-yaml";
29
29
 
@@ -64,7 +64,8 @@ var LI_COMMANDS = [
64
64
  ];
65
65
  var LI_MANAGED_PATHS = [
66
66
  ...LI_COMMANDS.map((c) => `.claude/commands/${c}`),
67
- ".claude/modules/linkedin/manifest.md"
67
+ ".claude/modules/linkedin/manifest.md",
68
+ ".claude/modules/linkedin/CLAUDE.md"
68
69
  ];
69
70
  var CONTENT_FILE_NAMES = [
70
71
  "INSTRUCTIONS.md",
@@ -91,6 +92,10 @@ var linkedinModule = {
91
92
  ".claude/modules/linkedin/manifest.md",
92
93
  render(tpl("modules/linkedin/manifest.md"), vars)
93
94
  ]);
95
+ files.push([
96
+ ".claude/modules/linkedin/CLAUDE.md",
97
+ render(tpl("modules/linkedin/CLAUDE.md"), vars)
98
+ ]);
94
99
  return files;
95
100
  },
96
101
  contentFiles(vars, contentDir) {
@@ -109,12 +114,53 @@ var linkedinModule = {
109
114
  const updated = { ...profile };
110
115
  delete updated.linkedin;
111
116
  return updated;
117
+ },
118
+ launchTasks: [
119
+ {
120
+ id: "open-drafts",
121
+ label: "Show open LinkedIn drafts",
122
+ template: "- Show any LinkedIn section drafts in `{{CONTENT_DIR}}/linkedin/` that have content to review."
123
+ }
124
+ ],
125
+ readmeBlock(vars) {
126
+ return [
127
+ "## LinkedIn module",
128
+ "",
129
+ "Drafts and refines your LinkedIn profile from your graph.",
130
+ "",
131
+ "### Folder additions",
132
+ "",
133
+ "```",
134
+ `${vars.CONTENT_DIR}/linkedin/`,
135
+ " INSTRUCTIONS.md \u2014 module rules and guidance",
136
+ " LinkedIn Current State.md \u2014 your current live profile copy",
137
+ " LinkedIn About.md \u2014 draft for the About section",
138
+ " LinkedIn Headline.md \u2014 draft for your headline",
139
+ " LinkedIn Experience.md \u2014 draft for your experience entries",
140
+ " LinkedIn Skills.md \u2014 draft for your skills section",
141
+ " LinkedIn Featured.md \u2014 draft for featured content",
142
+ " LinkedIn Activity.md \u2014 draft for activity/posts section",
143
+ "```",
144
+ "",
145
+ "### Commands",
146
+ "",
147
+ "| Command | What it does |",
148
+ "|---------|-------------|",
149
+ "| `/li-all` | Run all LinkedIn section drafts in sequence |",
150
+ "| `/li-about` | Draft or refine your LinkedIn About section |",
151
+ "| `/li-headline` | Draft or refine your LinkedIn headline |",
152
+ "| `/li-experience` | Draft or refine your LinkedIn experience entries |",
153
+ "| `/li-skills` | Draft or refine your LinkedIn skills section |",
154
+ "| `/li-featured` | Draft or refine your LinkedIn featured content |",
155
+ "| `/li-activity` | Draft or refine your LinkedIn activity section |"
156
+ ].join("\n");
112
157
  }
113
158
  };
114
159
 
115
160
  // src/modules/resume/index.ts
116
161
  var RESUME_MANAGED_PATHS = [
117
162
  ".claude/commands/resume-refresh.md",
163
+ ".claude/modules/resume/CLAUDE.md",
118
164
  ".claude/modules/resume/manifest.md"
119
165
  ];
120
166
  var CONTENT_FILE_NAMES2 = [
@@ -129,10 +175,10 @@ var resumeModule = {
129
175
  managedPaths: RESUME_MANAGED_PATHS,
130
176
  contentFileNames: CONTENT_FILE_NAMES2,
131
177
  managedFiles(vars) {
132
- const [commandPath, manifestPath] = RESUME_MANAGED_PATHS;
133
178
  return [
134
- [commandPath, render(tpl("modules/resume/commands/resume-refresh.md"), vars)],
135
- [manifestPath, render(tpl("modules/resume/manifest.md"), vars)]
179
+ [".claude/commands/resume-refresh.md", render(tpl("modules/resume/commands/resume-refresh.md"), vars)],
180
+ [".claude/modules/resume/CLAUDE.md", render(tpl("modules/resume/CLAUDE.md"), vars)],
181
+ [".claude/modules/resume/manifest.md", render(tpl("modules/resume/manifest.md"), vars)]
136
182
  ];
137
183
  },
138
184
  contentFiles(vars, contentDir) {
@@ -140,6 +186,35 @@ var resumeModule = {
140
186
  `${contentDir}/resume/${file}`,
141
187
  render(tpl(`modules/resume/graph/${file}`), vars)
142
188
  ]);
189
+ },
190
+ launchTasks: [
191
+ {
192
+ id: "resume-stale-check",
193
+ label: "Flag resume if it may be out of date",
194
+ template: "- Compare `{{CONTENT_DIR}}/resume/Resume Working Draft.md` against recent notes and flag if the resume looks out of date."
195
+ }
196
+ ],
197
+ readmeBlock(vars) {
198
+ return [
199
+ "## Resume module",
200
+ "",
201
+ "Keeps your resume current by synthesising it from your graph.",
202
+ "",
203
+ "### Folder additions",
204
+ "",
205
+ "```",
206
+ `${vars.CONTENT_DIR}/resume/`,
207
+ " INSTRUCTIONS.md \u2014 module rules and guidance",
208
+ " Resume Working Draft.md \u2014 the resume you are actively editing",
209
+ " Resume Last Submitted.md \u2014 the version you last sent to an employer",
210
+ "```",
211
+ "",
212
+ "### Commands",
213
+ "",
214
+ "| Command | What it does |",
215
+ "|---------|-------------|",
216
+ "| `/resume-refresh` | Refresh your resume working draft from your graph |"
217
+ ].join("\n");
143
218
  }
144
219
  // Resume has no module-specific profile fields — no onAdd/onRemove needed.
145
220
  };
@@ -258,6 +333,28 @@ function mergeSections(existing, newSections, storedChecksums, relativePath, ove
258
333
  }
259
334
  return { content: result, sections: outcomes };
260
335
  }
336
+ function removeSection(id, content) {
337
+ const sections = parseSections(content);
338
+ const section = sections.find((s) => s.id === id);
339
+ if (!section) return content;
340
+ const { start, end } = section;
341
+ let before = content.slice(0, start);
342
+ before = before.replace(/(\r?\n)+$/, "");
343
+ let after = content.slice(end);
344
+ after = after.replace(/^(\r?\n)+/, "");
345
+ let result;
346
+ if (before === "" && after === "") {
347
+ result = "";
348
+ } else if (before === "") {
349
+ result = after;
350
+ } else if (after === "") {
351
+ result = before;
352
+ } else {
353
+ result = before + "\n\n" + after;
354
+ }
355
+ result = result.replace(/\n{3,}/g, "\n\n");
356
+ return result;
357
+ }
261
358
  function inspectSections(relativePath, existing, storedChecksums) {
262
359
  const sections = parseSections(existing);
263
360
  const editedIds = [];
@@ -387,6 +484,66 @@ function writeState(root, state) {
387
484
  );
388
485
  }
389
486
 
487
+ // src/launch-tasks.ts
488
+ var BASE_LAUNCH_TASKS = [
489
+ {
490
+ id: "today-focus",
491
+ label: "Ask what to focus on today",
492
+ template: "- Ask the user what they want to focus on today and note it before proceeding."
493
+ },
494
+ {
495
+ id: "recent-notes",
496
+ label: "Summarise notes changed in the last 7 days",
497
+ template: "- Summarise any notes in `{{CONTENT_DIR}}/notes/` modified in the last 7 days, one line each."
498
+ },
499
+ {
500
+ id: "open-posts",
501
+ label: "List unfinished posts",
502
+ template: "- List any drafts in `{{CONTENT_DIR}}/posts/` that look unfinished and offer to continue one."
503
+ }
504
+ ];
505
+ function availableLaunchTasks(modules) {
506
+ const tasks = BASE_LAUNCH_TASKS.map((t) => ({
507
+ nsId: `base/${t.id}`,
508
+ label: t.label,
509
+ template: t.template,
510
+ source: "base"
511
+ }));
512
+ for (const moduleId of modules) {
513
+ const def = getModule(moduleId);
514
+ if (def?.launchTasks) {
515
+ for (const t of def.launchTasks) {
516
+ tasks.push({
517
+ nsId: `${moduleId}/${t.id}`,
518
+ label: t.label,
519
+ template: t.template,
520
+ source: moduleId
521
+ });
522
+ }
523
+ }
524
+ }
525
+ return tasks;
526
+ }
527
+ function renderLaunchSection(selectedNsIds, modules) {
528
+ if (!selectedNsIds || selectedNsIds.length === 0) return null;
529
+ const avail = availableLaunchTasks(modules);
530
+ const availMap = new Map(avail.map((t) => [t.nsId, t]));
531
+ const resolved = selectedNsIds.map((id) => availMap.get(id)).filter((t) => t !== void 0);
532
+ if (resolved.length === 0) return null;
533
+ return ["## Launch tasks", "", ...resolved.map((t) => t.template)].join("\n");
534
+ }
535
+ function pruneLaunchTasks(selectedNsIds, modules) {
536
+ if (!selectedNsIds || selectedNsIds.length === 0) return [];
537
+ const avail = new Set(availableLaunchTasks(modules).map((t) => t.nsId));
538
+ return selectedNsIds.filter((id) => avail.has(id));
539
+ }
540
+ function launchSelectionError(values) {
541
+ if (values.length > 5) {
542
+ return "You can select at most 5 launch tasks.";
543
+ }
544
+ return void 0;
545
+ }
546
+
390
547
  // src/scaffold.ts
391
548
  function writeRaw(targetDir, relativePath, content) {
392
549
  const full = join5(targetDir, relativePath);
@@ -417,6 +574,11 @@ function validateManifestFrontmatter(moduleName, content) {
417
574
  }
418
575
  function profileToVars(profile, liProfileUrl) {
419
576
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
577
+ const modulesSection = (profile.modules ?? []).length ? profile.modules.map((id) => {
578
+ const def = getModule(id);
579
+ const label2 = def?.label ?? id;
580
+ return `- [${label2} module context](.claude/modules/${id}/CLAUDE.md)`;
581
+ }).join("\n") : "_No modules installed._";
420
582
  return {
421
583
  PATINA_NAME: profile.patina_name,
422
584
  USER_NAME: profile.name,
@@ -431,11 +593,13 @@ function profileToVars(profile, liProfileUrl) {
431
593
  STALENESS_THRESHOLD: (() => {
432
594
  const d = Number(profile.staleness_threshold_days ?? 30);
433
595
  return String(Number.isFinite(d) && d > 0 ? d : 30);
434
- })()
596
+ })(),
597
+ MODULES_SECTION: modulesSection
435
598
  };
436
599
  }
437
600
  function baseManagedFiles(vars, editor, targetDir) {
438
601
  const files = [
602
+ ["README.md", render(tpl("README.md"), vars)],
439
603
  ["CLAUDE.md", render(tpl("CLAUDE.md"), vars)],
440
604
  [".claude/settings.json", tpl(".claude/settings.json")],
441
605
  [".claude/commands/add.md", render(tpl(".claude/commands/add.md"), vars)],
@@ -472,7 +636,8 @@ async function scaffold(opts) {
472
636
  editor,
473
637
  modules,
474
638
  liProfileUrl,
475
- contentDir
639
+ contentDir,
640
+ launchTasks = []
476
641
  } = opts;
477
642
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
478
643
  const tempProfile = {
@@ -486,13 +651,27 @@ async function scaffold(opts) {
486
651
  modules,
487
652
  content_dir: contentDir,
488
653
  created: today,
654
+ ...launchTasks.length ? { launch_tasks: launchTasks } : {},
489
655
  ...modules.includes("linkedin") && liProfileUrl ? { linkedin: { profile_url: liProfileUrl } } : {}
490
656
  };
491
657
  const vars = profileToVars(tempProfile, liProfileUrl);
492
658
  mkdirSync2(targetDir, { recursive: true });
493
659
  const checksums = {};
660
+ const baseFiles = baseManagedFiles(vars, editor, targetDir);
661
+ const readmePath = join5(targetDir, "README.md");
662
+ const filteredBaseFiles = baseFiles.filter(([rel]) => {
663
+ if (rel === "README.md") {
664
+ if (existsSync5(readmePath)) {
665
+ const existing = readFileSync6(readmePath, "utf8");
666
+ if (!hasFences(existing)) {
667
+ return false;
668
+ }
669
+ }
670
+ }
671
+ return true;
672
+ });
494
673
  const managedFiles = [
495
- ...baseManagedFiles(vars, editor, targetDir),
674
+ ...filteredBaseFiles,
496
675
  ...modules.flatMap((m) => moduleManagedFiles(m, vars))
497
676
  ];
498
677
  for (const module of modules) {
@@ -506,6 +685,27 @@ async function scaffold(opts) {
506
685
  checksums[`${relativePath}:${s.id}`] = s.newChecksum;
507
686
  }
508
687
  }
688
+ for (const module of modules) {
689
+ const def = getModule(module);
690
+ if (def?.readmeBlock) {
691
+ const block = renderSection(module, def.readmeBlock(vars));
692
+ const result = writeManagedFile(targetDir, "README.md", block, checksums);
693
+ checksums["README.md"] = result.checksum;
694
+ for (const s of result.sections ?? []) {
695
+ checksums[`README.md:${s.id}`] = s.newChecksum;
696
+ }
697
+ }
698
+ }
699
+ const rawLaunch = renderLaunchSection(launchTasks, modules);
700
+ const expandedLaunch = rawLaunch ? render(rawLaunch, vars) : null;
701
+ if (expandedLaunch) {
702
+ const launchBlock = renderSection("launch", expandedLaunch);
703
+ const result = writeManagedFile(targetDir, "CLAUDE.md", launchBlock, checksums);
704
+ checksums["CLAUDE.md"] = result.checksum;
705
+ for (const s of result.sections ?? []) {
706
+ checksums[`CLAUDE.md:${s.id}`] = s.newChecksum;
707
+ }
708
+ }
509
709
  const baseDirs = ["notes", "skills", "posts"];
510
710
  for (const dir of baseDirs) {
511
711
  touch(targetDir, `${contentDir}/${dir}/.gitkeep`);
@@ -526,16 +726,16 @@ ${STATE_FILENAME}
526
726
  }
527
727
 
528
728
  // src/validate.ts
529
- import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync } from "fs";
729
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync } from "fs";
530
730
  import { join as join6, relative, sep, basename } from "path";
531
731
  var NOTES = CONTENT_SUBDIRS[0];
532
732
  var SKILLS = CONTENT_SUBDIRS[1];
533
733
  var POSTS = CONTENT_SUBDIRS[2];
534
734
  function findPatinaRoot(cwd) {
535
- return existsSync5(join6(cwd, "profile.yaml")) ? cwd : null;
735
+ return existsSync6(join6(cwd, "profile.yaml")) ? cwd : null;
536
736
  }
537
737
  function listMarkdownFiles(dir) {
538
- if (!existsSync5(dir)) return [];
738
+ if (!existsSync6(dir)) return [];
539
739
  const results = [];
540
740
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
541
741
  const fullPath = join6(dir, entry.name);
@@ -598,7 +798,7 @@ function checkSkillNotes(root, profile) {
598
798
  const noteSlugs = new Set(noteFiles.map((f) => basename(f, ".md")));
599
799
  const issues = [];
600
800
  for (const skillFile of listMarkdownFiles(skillsDir)) {
601
- const content = readFileSync6(skillFile, "utf8");
801
+ const content = readFileSync7(skillFile, "utf8");
602
802
  const links = extractWikiLinks(content);
603
803
  for (const { target, line } of links) {
604
804
  if (!noteSlugs.has(target)) {
@@ -625,7 +825,7 @@ function checkWikiLinks(root, profile) {
625
825
  ...listMarkdownFiles(postsDir)
626
826
  ];
627
827
  for (const file of filesToScan) {
628
- const content = readFileSync6(file, "utf8");
828
+ const content = readFileSync7(file, "utf8");
629
829
  const links = extractWikiLinks(content);
630
830
  for (const { target, line } of links) {
631
831
  if (!noteSlugs.has(target)) {
@@ -644,8 +844,8 @@ function checkExclusions(root, profile) {
644
844
  const contentDir = join6(root, profile.content_dir ?? "graph");
645
845
  const notesDir = join6(contentDir, NOTES);
646
846
  const exclusionsPath = join6(notesDir, "exclusions.md");
647
- if (!existsSync5(exclusionsPath)) return [];
648
- const content = readFileSync6(exclusionsPath, "utf8");
847
+ if (!existsSync6(exclusionsPath)) return [];
848
+ const content = readFileSync7(exclusionsPath, "utf8");
649
849
  const items = parseExclusions(content);
650
850
  if (items.length === 0) return [];
651
851
  const skillsDir = join6(contentDir, SKILLS);
@@ -657,7 +857,7 @@ function checkExclusions(root, profile) {
657
857
  const issues = [];
658
858
  const seen = /* @__PURE__ */ new Set();
659
859
  for (const file of filesToScan) {
660
- const fileContent = readFileSync6(file, "utf8");
860
+ const fileContent = readFileSync7(file, "utf8");
661
861
  const lines = fileContent.split("\n");
662
862
  for (let i = 0; i < lines.length; i++) {
663
863
  const lineText = lines[i];
@@ -742,6 +942,58 @@ function label(text2) {
742
942
  var MULTISELECT_HINT = `
743
943
  ${chalk.hex("#64748B")("\u2191\u2193 to move \xB7 space to select \xB7 enter to confirm")}`;
744
944
  var OPTIONAL_HINT = ` ${chalk.dim.italic("optional, but helps a lot \u2014 hit enter to skip")}`;
945
+ function applyLaunchBlock(cwd, launchTasks, modules, vars, checksums, overwrite) {
946
+ const updated = [];
947
+ const skipped = [];
948
+ const keptSections = [];
949
+ const rawLaunch = renderLaunchSection(launchTasks, modules);
950
+ if (rawLaunch) {
951
+ const block = renderSection("launch", render(rawLaunch, vars));
952
+ const result = writeManagedFile(cwd, "CLAUDE.md", block, checksums, overwrite);
953
+ checksums["CLAUDE.md"] = result.checksum;
954
+ for (const s of result.sections ?? []) {
955
+ const sKey = `CLAUDE.md:${s.id}`;
956
+ if (s.outcome !== "skipped") checksums[sKey] = s.newChecksum;
957
+ else keptSections.push(sKey);
958
+ }
959
+ if (result.outcome === "skipped") skipped.push("CLAUDE.md");
960
+ else updated.push("CLAUDE.md");
961
+ } else {
962
+ const claudePath = join7(cwd, "CLAUDE.md");
963
+ if (existsSync7(claudePath)) {
964
+ const before = readFileSync8(claudePath, "utf8");
965
+ const editedIds = inspectSections("CLAUDE.md", before, checksums);
966
+ if (!editedIds.includes("launch") || overwrite?.has("launch")) {
967
+ const after = removeSection("launch", before);
968
+ if (after !== before) {
969
+ writeFileSync4(claudePath, after, "utf8");
970
+ checksums["CLAUDE.md"] = hashContent(after);
971
+ delete checksums["CLAUDE.md:launch"];
972
+ updated.push("CLAUDE.md");
973
+ }
974
+ } else {
975
+ keptSections.push("CLAUDE.md:launch");
976
+ skipped.push("CLAUDE.md");
977
+ }
978
+ }
979
+ }
980
+ return { updated, skipped, keptSections };
981
+ }
982
+ async function promptLaunchTasks(avail, initial) {
983
+ const selected = await p.multiselect({
984
+ message: `Which tasks should run every time you launch Patina?${MULTISELECT_HINT}`,
985
+ options: avail.map((t) => ({
986
+ value: t.nsId,
987
+ label: t.label,
988
+ hint: chalk.hex("#64748B")(t.source)
989
+ })),
990
+ initialValues: initial,
991
+ required: false,
992
+ validate: launchSelectionError
993
+ });
994
+ if (p.isCancel(selected)) onCancel();
995
+ return Array.isArray(selected) ? selected : [];
996
+ }
745
997
  async function main() {
746
998
  printBanner();
747
999
  const cwd = process.cwd();
@@ -848,6 +1100,18 @@ async function runInstall(cwd) {
848
1100
  });
849
1101
  liProfileUrl = typeof url === "string" ? url : "";
850
1102
  }
1103
+ let launchTasks = [];
1104
+ const availTasks = availableLaunchTasks(modules);
1105
+ if (availTasks.length > 0) {
1106
+ const setupLaunch = await p.confirm({
1107
+ message: "Would you like to set up launch tasks?",
1108
+ initialValue: false
1109
+ });
1110
+ if (p.isCancel(setupLaunch)) onCancel();
1111
+ if (setupLaunch) {
1112
+ launchTasks = await promptLaunchTasks(availTasks, []);
1113
+ }
1114
+ }
851
1115
  const slug = slugify(identity.patinaName);
852
1116
  const targetDir = resolve(cwd, slug);
853
1117
  const s = p.spinner();
@@ -870,7 +1134,8 @@ async function runInstall(cwd) {
870
1134
  editor: setup.editor,
871
1135
  modules,
872
1136
  liProfileUrl,
873
- contentDir: "graph"
1137
+ contentDir: "graph",
1138
+ launchTasks
874
1139
  });
875
1140
  s.stop(chalk.green("Done."));
876
1141
  } catch (err) {
@@ -893,8 +1158,8 @@ function writeProfile(cwd, profile) {
893
1158
  }
894
1159
  function removeManagedFileIfUnmodified(targetDir, rel, stored) {
895
1160
  const fullPath = join7(targetDir, rel);
896
- if (!existsSync6(fullPath)) return "deleted";
897
- const fileContent = readFileSync7(fullPath, "utf8");
1161
+ if (!existsSync7(fullPath)) return "deleted";
1162
+ const fileContent = readFileSync8(fullPath, "utf8");
898
1163
  if (hasFences(fileContent)) {
899
1164
  const editedIds = inspectSections(rel, fileContent, stored);
900
1165
  if (editedIds.length > 0) {
@@ -916,10 +1181,11 @@ async function runUpdate(cwd) {
916
1181
  p.intro(chalk.hex("#94A3B8")(`Found: ${chalk.bold.white(profile.patina_name || "patina")}`));
917
1182
  p.note(
918
1183
  [
919
- `${chalk.hex("#64748B")("Name:")} ${profile.name}`,
920
- `${chalk.hex("#64748B")("Title:")} ${profile.title || "\u2014"}`,
921
- `${chalk.hex("#64748B")("Company:")} ${profile.work?.company_name || "\u2014"}`,
922
- `${chalk.hex("#64748B")("Modules:")} ${profile.modules?.join(", ") || "none"}`
1184
+ `${chalk.hex("#64748B")("Name:")} ${profile.name}`,
1185
+ `${chalk.hex("#64748B")("Title:")} ${profile.title || "\u2014"}`,
1186
+ `${chalk.hex("#64748B")("Company:")} ${profile.work?.company_name || "\u2014"}`,
1187
+ `${chalk.hex("#64748B")("Modules:")} ${profile.modules?.join(", ") || "none"}`,
1188
+ `${chalk.hex("#64748B")("Launch tasks:")} ${profile.launch_tasks?.length ?? 0}`
923
1189
  ].join("\n"),
924
1190
  label("Current profile")
925
1191
  );
@@ -928,6 +1194,7 @@ async function runUpdate(cwd) {
928
1194
  options: [
929
1195
  { value: "profile", label: "Update personal info" },
930
1196
  { value: "modules", label: "Add or remove modules" },
1197
+ { value: "launch-tasks", label: "Set up launch tasks", hint: chalk.hex("#64748B")("tasks Claude runs every session") },
931
1198
  { value: "validate", label: "Run health check", hint: chalk.hex("#64748B")("check for broken links and excluded items") },
932
1199
  { value: "nothing", label: "Nothing \u2014 just checking" }
933
1200
  ]
@@ -940,6 +1207,8 @@ async function runUpdate(cwd) {
940
1207
  await runUpdateProfile(cwd, profile);
941
1208
  } else if (action === "modules") {
942
1209
  await runUpdateModules(cwd, profile);
1210
+ } else if (action === "launch-tasks") {
1211
+ await runUpdateLaunchTasks(cwd, profile);
943
1212
  } else if (action === "validate") {
944
1213
  await runValidate(cwd, profile);
945
1214
  }
@@ -985,6 +1254,45 @@ function applyProfileUpdate(cwd, profile, fields, overwrite) {
985
1254
  updated.push(rel);
986
1255
  }
987
1256
  }
1257
+ for (const module of updatedProfile.modules) {
1258
+ const def = getModule(module);
1259
+ if (def?.readmeBlock) {
1260
+ const readmePath = join7(cwd, "README.md");
1261
+ const readmeExists = existsSync7(readmePath);
1262
+ const readmeHasFences = readmeExists ? hasFences(readFileSync8(readmePath, "utf8")) : false;
1263
+ if (!readmeExists || readmeHasFences) {
1264
+ const block = renderSection(module, def.readmeBlock(vars));
1265
+ const result = writeManagedFile(cwd, "README.md", block, newChecksums, overwrite);
1266
+ newChecksums["README.md"] = result.checksum;
1267
+ for (const s of result.sections ?? []) {
1268
+ const sKey = `README.md:${s.id}`;
1269
+ if (s.outcome !== "skipped") newChecksums[sKey] = s.newChecksum;
1270
+ else {
1271
+ newChecksums[sKey] = stored[sKey] ?? "";
1272
+ keptSections.push(sKey);
1273
+ }
1274
+ }
1275
+ if (result.outcome === "skipped") {
1276
+ skipped.push(`README.md:${module}`);
1277
+ } else if (result.outcome !== "updated" || result.sections?.some((s) => s.id === module && s.outcome !== "unchanged")) {
1278
+ updated.push(`README.md:${module}`);
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+ if (updatedProfile.launch_tasks?.length) {
1284
+ const launchResult = applyLaunchBlock(
1285
+ cwd,
1286
+ updatedProfile.launch_tasks,
1287
+ updatedProfile.modules,
1288
+ vars,
1289
+ newChecksums,
1290
+ overwrite
1291
+ );
1292
+ updated.push(...launchResult.updated);
1293
+ skipped.push(...launchResult.skipped);
1294
+ keptSections.push(...launchResult.keptSections);
1295
+ }
988
1296
  for (const [rel, hash] of Object.entries(stored)) {
989
1297
  if (!(rel in newChecksums)) {
990
1298
  newChecksums[rel] = hash;
@@ -1079,8 +1387,8 @@ async function runUpdateProfile(cwd, profile) {
1079
1387
  for (const [rel, content] of previewFiles) {
1080
1388
  if (hasFences(content)) {
1081
1389
  const fullPath = join7(cwd, rel);
1082
- if (existsSync6(fullPath)) {
1083
- const existingContent = readFileSync7(fullPath, "utf8");
1390
+ if (existsSync7(fullPath)) {
1391
+ const existingContent = readFileSync8(fullPath, "utf8");
1084
1392
  const editedIds = inspectSections(rel, existingContent, storedChecksums);
1085
1393
  for (const sectionId of editedIds) {
1086
1394
  const confirmed = await p.confirm({
@@ -1142,12 +1450,28 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
1142
1450
  }
1143
1451
  for (const [relativePath, content] of moduleContentFiles(module, vars, contentDir)) {
1144
1452
  const fullPath = join7(cwd, relativePath);
1145
- if (!existsSync6(fullPath)) {
1453
+ if (!existsSync7(fullPath)) {
1146
1454
  mkdirSync3(dirname4(fullPath), { recursive: true });
1147
1455
  writeFileSync4(fullPath, content, "utf8");
1148
1456
  added.push(relativePath);
1149
1457
  }
1150
1458
  }
1459
+ if (def?.readmeBlock) {
1460
+ const readmePath = join7(cwd, "README.md");
1461
+ const readmeExists = existsSync7(readmePath);
1462
+ const readmeHasFences = readmeExists ? hasFences(readFileSync8(readmePath, "utf8")) : false;
1463
+ if (!readmeExists || readmeHasFences) {
1464
+ const block = renderSection(module, def.readmeBlock(vars));
1465
+ const result = writeManagedFile(cwd, "README.md", block, newChecksums);
1466
+ newChecksums["README.md"] = result.checksum;
1467
+ for (const s of result.sections ?? []) {
1468
+ newChecksums[`README.md:${s.id}`] = s.newChecksum;
1469
+ }
1470
+ if (result.outcome !== "skipped") added.push(`README.md:${module}`);
1471
+ } else {
1472
+ kept.push("README.md");
1473
+ }
1474
+ }
1151
1475
  if (!updatedProfile.modules.includes(module)) {
1152
1476
  updatedProfile.modules = [...updatedProfile.modules, module];
1153
1477
  }
@@ -1168,16 +1492,103 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
1168
1492
  kept.push(rel);
1169
1493
  }
1170
1494
  }
1495
+ const readmePath = join7(cwd, "README.md");
1496
+ if (existsSync7(readmePath)) {
1497
+ const before = readFileSync8(readmePath, "utf8");
1498
+ const editedIds = inspectSections("README.md", before, stored);
1499
+ if (!editedIds.includes(module)) {
1500
+ const after = removeSection(module, before);
1501
+ if (after !== before) {
1502
+ writeFileSync4(readmePath, after, "utf8");
1503
+ newChecksums["README.md"] = hashContent(after);
1504
+ delete newChecksums[`README.md:${module}`];
1505
+ deleted.push(`README.md:${module}`);
1506
+ }
1507
+ } else {
1508
+ keptSections.push(`README.md:${module}`);
1509
+ }
1510
+ }
1171
1511
  updatedProfile.modules = updatedProfile.modules.filter((m) => m !== module);
1172
1512
  if (def?.onRemove) {
1173
1513
  updatedProfile = def.onRemove(updatedProfile);
1174
1514
  }
1175
1515
  }
1516
+ const prunedTasks = pruneLaunchTasks(updatedProfile.launch_tasks, updatedProfile.modules);
1517
+ updatedProfile = { ...updatedProfile, launch_tasks: prunedTasks.length ? prunedTasks : void 0 };
1518
+ const finalVars = profileToVars(updatedProfile);
1519
+ for (const [rel, content] of baseManagedFiles(finalVars, updatedProfile.editor, cwd)) {
1520
+ const result = writeManagedFile(cwd, rel, content, newChecksums);
1521
+ newChecksums[rel] = result.checksum;
1522
+ for (const s of result.sections ?? []) {
1523
+ const sKey = `${rel}:${s.id}`;
1524
+ if (s.outcome !== "skipped") newChecksums[sKey] = s.newChecksum;
1525
+ else {
1526
+ newChecksums[sKey] = newChecksums[sKey] ?? "";
1527
+ keptSections.push(sKey);
1528
+ }
1529
+ }
1530
+ }
1531
+ const launchResult = applyLaunchBlock(cwd, updatedProfile.launch_tasks ?? [], updatedProfile.modules, finalVars, newChecksums);
1532
+ keptSections.push(...launchResult.keptSections);
1176
1533
  writeState(cwd, { checksums: newChecksums });
1177
1534
  const finalProfile = stripLegacyChecksums(updatedProfile);
1178
1535
  writeProfile(cwd, finalProfile);
1179
1536
  return { profile: finalProfile, added, skipped: skippedFiles, deleted, kept, keptSections };
1180
1537
  }
1538
+ function applyLaunchTaskUpdate(cwd, profile, launchTasks, overwrite) {
1539
+ const updatedProfile = {
1540
+ ...profile,
1541
+ launch_tasks: launchTasks.length ? launchTasks : void 0
1542
+ };
1543
+ const vars = profileToVars(updatedProfile);
1544
+ const stored = readState(cwd, profile).checksums;
1545
+ const newChecksums = { ...stored };
1546
+ const { updated, skipped, keptSections } = applyLaunchBlock(
1547
+ cwd,
1548
+ launchTasks,
1549
+ profile.modules ?? [],
1550
+ vars,
1551
+ newChecksums,
1552
+ overwrite
1553
+ );
1554
+ writeState(cwd, { checksums: newChecksums });
1555
+ const profileToWrite = stripLegacyChecksums(updatedProfile);
1556
+ writeProfile(cwd, profileToWrite);
1557
+ return { profile: profileToWrite, updated, skipped, keptSections };
1558
+ }
1559
+ async function runUpdateLaunchTasks(cwd, profile) {
1560
+ const avail = availableLaunchTasks(profile.modules ?? []);
1561
+ const initial = pruneLaunchTasks(profile.launch_tasks, profile.modules ?? []);
1562
+ const overwriteSet = /* @__PURE__ */ new Set();
1563
+ const claudePath = join7(cwd, "CLAUDE.md");
1564
+ if (existsSync7(claudePath)) {
1565
+ const storedChecksums = readState(cwd, profile).checksums;
1566
+ const existingContent = readFileSync8(claudePath, "utf8");
1567
+ const editedIds = inspectSections("CLAUDE.md", existingContent, storedChecksums);
1568
+ if (editedIds.includes("launch")) {
1569
+ const confirmed = await p.confirm({
1570
+ message: `Section 'launch' in CLAUDE.md has been manually edited. Overwrite?`,
1571
+ initialValue: false
1572
+ });
1573
+ if (p.isCancel(confirmed)) onCancel();
1574
+ if (confirmed) overwriteSet.add("launch");
1575
+ }
1576
+ }
1577
+ const selected = await promptLaunchTasks(avail, initial);
1578
+ const { updated, skipped, keptSections } = applyLaunchTaskUpdate(cwd, profile, selected, overwriteSet);
1579
+ const summaryLines = [];
1580
+ if (updated.length > 0) {
1581
+ summaryLines.push(chalk.hex("#94A3B8")(`Updated: ${updated.join(", ")}`));
1582
+ }
1583
+ if (keptSections.length > 0) {
1584
+ summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${keptSections.join(", ")}`));
1585
+ }
1586
+ if (skipped.length > 0) {
1587
+ summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${skipped.join(", ")}`));
1588
+ }
1589
+ p.note(summaryLines.join("\n") || "No changes.", label("Done"));
1590
+ p.outro(chalk.hex("#94A3B8")("Launch tasks updated."));
1591
+ }
1181
1592
  async function runUpdateModules(cwd, profile) {
1182
1593
  const currentModules = profile.modules ?? [];
1183
1594
  const selected = await p.multiselect({
@@ -1201,6 +1612,16 @@ async function runUpdateModules(cwd, profile) {
1201
1612
  p.outro(chalk.hex("#94A3B8")("No changes \u2014 modules unchanged."));
1202
1613
  return;
1203
1614
  }
1615
+ const changeLines = [];
1616
+ for (const m of toAdd) {
1617
+ const def = getModule(m);
1618
+ changeLines.push(`Adding ${def?.label ?? m}: appends a section to README.md, adds a link to CLAUDE.md`);
1619
+ }
1620
+ for (const m of toRemove) {
1621
+ const def = getModule(m);
1622
+ changeLines.push(`Removing ${def?.label ?? m}: removes its section from README.md and its link from CLAUDE.md`);
1623
+ }
1624
+ p.note(changeLines.join("\n"), label("Planned changes"));
1204
1625
  let liProfileUrl;
1205
1626
  if (toAdd.includes("linkedin") && !profile.linkedin?.profile_url) {
1206
1627
  const url = await p.text({
@@ -69,3 +69,9 @@ Skip any area with nothing stale. If everything is fresh, say so in one line. Ke
69
69
  |---------|-------------|
70
70
  | `/add <description>` | Add a skill, project, or experience to your graph |
71
71
  | `/reflect [slug]` | Review your graph for gaps, completions, and stale skills — also runs all installed module hooks |
72
+
73
+ ## Modules
74
+
75
+ <!-- patina:modules:start -->
76
+ {{MODULES_SECTION}}
77
+ <!-- patina:modules:end -->
@@ -0,0 +1,12 @@
1
+ # {{PATINA_NAME}}
2
+
3
+ <!-- patina:base:start -->
4
+ This is your patina — a personal knowledge base for your professional story.
5
+
6
+ - Profile and config live in `profile.yaml` and `CLAUDE.md`.
7
+ - Your notes, skills, and posts live in `{{CONTENT_DIR}}/`.
8
+
9
+ ## Installed modules
10
+
11
+ Module-specific sections are appended below as you install modules.
12
+ <!-- patina:base:end -->
@@ -0,0 +1,35 @@
1
+ # LinkedIn Module Context
2
+
3
+ This module helps you draft and refine your LinkedIn profile using your patina graph as the source of truth.
4
+
5
+ ## Folder structure
6
+
7
+ ```
8
+ {{CONTENT_DIR}}/linkedin/
9
+ INSTRUCTIONS.md — module-specific rules and guidance
10
+ LinkedIn Current State.md — your current live profile copy
11
+ LinkedIn About.md — draft for the About section
12
+ LinkedIn Headline.md — draft for your headline
13
+ LinkedIn Experience.md — draft for your experience entries
14
+ LinkedIn Skills.md — draft for your skills section
15
+ LinkedIn Featured.md — draft for featured content
16
+ LinkedIn Activity.md — draft for activity/posts section
17
+ ```
18
+
19
+ ## Slash commands
20
+
21
+ | Command | What it does |
22
+ |---------|-------------|
23
+ | `/li-all` | Run all LinkedIn section drafts in sequence |
24
+ | `/li-about` | Draft or refine your LinkedIn About section |
25
+ | `/li-headline` | Draft or refine your LinkedIn headline |
26
+ | `/li-experience` | Draft or refine your LinkedIn experience entries |
27
+ | `/li-skills` | Draft or refine your LinkedIn skills section |
28
+ | `/li-featured` | Draft or refine your LinkedIn featured content |
29
+ | `/li-activity` | Draft or refine your LinkedIn activity section |
30
+
31
+ ## How it works
32
+
33
+ LinkedIn commands read your `{{CONTENT_DIR}}/` graph — notes, skills, and posts — and draft profile copy grounded in that evidence. They never invent claims not supported by your notes.
34
+
35
+ The `/reflect` command also runs the LinkedIn reflect hook (`/li-all`) to keep your drafts current.
@@ -0,0 +1,24 @@
1
+ # Resume Module Context
2
+
3
+ This module helps you keep your resume current by synthesising it from your patina graph.
4
+
5
+ ## Folder structure
6
+
7
+ ```
8
+ {{CONTENT_DIR}}/resume/
9
+ INSTRUCTIONS.md — module-specific rules and guidance
10
+ Resume Working Draft.md — the resume you are actively editing
11
+ Resume Last Submitted.md — the version you last sent to an employer
12
+ ```
13
+
14
+ ## Slash commands
15
+
16
+ | Command | What it does |
17
+ |---------|-------------|
18
+ | `/resume-refresh` | Refresh your resume working draft from your graph |
19
+
20
+ ## How it works
21
+
22
+ The `/resume-refresh` command reads your `{{CONTENT_DIR}}/` graph — notes, skills, and experience — and updates your Resume Working Draft to reflect your current professional state. It never overwrites Resume Last Submitted; that file is yours to update manually when you send an application.
23
+
24
+ The working draft is compared against the last submitted version so you can see what has changed before sending.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-patina",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Personal professional knowledge graph — setup and management",
5
5
  "type": "module",
6
6
  "bin": {