my-patina 0.5.0 → 0.7.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, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
10
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
11
11
  import yaml3 from "js-yaml";
12
12
 
13
13
  // src/detect.ts
@@ -35,7 +35,7 @@ function render(template, vars) {
35
35
  }
36
36
 
37
37
  // src/upgrade.ts
38
- import { existsSync as existsSync3, mkdirSync, writeFileSync } from "fs";
38
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
39
39
  import { join as join3, dirname as dirname2 } from "path";
40
40
 
41
41
  // src/checksums.ts
@@ -163,8 +163,122 @@ var CONTENT_SUBDIRS = ["notes", "skills", "posts"];
163
163
  var MODULE_MANAGED_FILES = Object.fromEntries(MODULES.map((m) => [m.id, m.managedPaths]));
164
164
  var MODULE_CONTENT_FILES = Object.fromEntries(MODULES.map((m) => [m.id, m.contentFileNames]));
165
165
 
166
+ // src/sections.ts
167
+ function hasFences(content) {
168
+ return parseSections(content).length > 0;
169
+ }
170
+ function parseSections(content) {
171
+ const sections = [];
172
+ const startRe = /<!-- patina:([a-z0-9-]+):start -->/g;
173
+ let match;
174
+ while ((match = startRe.exec(content)) !== null) {
175
+ const id = match[1];
176
+ const startIdx = match.index;
177
+ const afterStart = match.index + match[0].length;
178
+ const endMarker = `<!-- patina:${id}:end -->`;
179
+ const endIdx = content.indexOf(endMarker, afterStart);
180
+ if (endIdx === -1) {
181
+ continue;
182
+ }
183
+ let innerStart = afterStart;
184
+ if (content[innerStart] === "\r") innerStart++;
185
+ if (content[innerStart] === "\n") innerStart++;
186
+ let innerEnd = endIdx;
187
+ if (innerEnd > 0 && content[innerEnd - 1] === "\n") innerEnd--;
188
+ if (innerEnd > 0 && content[innerEnd - 1] === "\r") innerEnd--;
189
+ const inner = content.slice(innerStart, innerEnd).replace(/\r\n/g, "\n");
190
+ sections.push({
191
+ id,
192
+ inner,
193
+ start: startIdx,
194
+ end: endIdx + endMarker.length
195
+ });
196
+ }
197
+ return sections;
198
+ }
199
+ function renderSection(id, innerContent) {
200
+ return `<!-- patina:${id}:start -->
201
+ ${innerContent}
202
+ <!-- patina:${id}:end -->`;
203
+ }
204
+ function mergeSections(existing, newSections, storedChecksums, relativePath, overwrite) {
205
+ const existingSections = parseSections(existing);
206
+ const outcomes = [];
207
+ const existingMap = new Map(
208
+ existingSections.map((s) => [s.id, s])
209
+ );
210
+ let result = existing;
211
+ const replacements = [];
212
+ for (const section of existingSections) {
213
+ const { id, inner } = section;
214
+ if (id in newSections) {
215
+ const newInner = newSections[id];
216
+ const storedKey = `${relativePath}:${id}`;
217
+ const storedHash = storedChecksums[storedKey];
218
+ const currentHash = hashContent(inner);
219
+ if (overwrite.has(id)) {
220
+ const normalized = newInner.replace(/\r\n/g, "\n");
221
+ replacements.push({
222
+ start: section.start,
223
+ end: section.end,
224
+ replacement: renderSection(id, normalized)
225
+ });
226
+ outcomes.push({ id, outcome: "updated", newChecksum: hashContent(normalized) });
227
+ } else if (storedHash && currentHash !== storedHash) {
228
+ outcomes.push({ id, outcome: "skipped", newChecksum: storedHash });
229
+ } else {
230
+ const normalized = newInner.replace(/\r\n/g, "\n");
231
+ const normalizedHash = hashContent(normalized);
232
+ if (normalizedHash === currentHash) {
233
+ outcomes.push({ id, outcome: "unchanged", newChecksum: currentHash });
234
+ } else {
235
+ replacements.push({
236
+ start: section.start,
237
+ end: section.end,
238
+ replacement: renderSection(id, normalized)
239
+ });
240
+ outcomes.push({ id, outcome: "updated", newChecksum: normalizedHash });
241
+ }
242
+ }
243
+ } else {
244
+ outcomes.push({ id, outcome: "unchanged", newChecksum: hashContent(inner) });
245
+ }
246
+ }
247
+ replacements.sort((a, b) => b.start - a.start);
248
+ for (const { start, end, replacement } of replacements) {
249
+ result = result.slice(0, start) + replacement + result.slice(end);
250
+ }
251
+ for (const [id, innerContent] of Object.entries(newSections)) {
252
+ if (!existingMap.has(id)) {
253
+ const normalized = innerContent.replace(/\r\n/g, "\n");
254
+ const block = renderSection(id, normalized);
255
+ result = result.endsWith("\n") ? result + block + "\n" : result + "\n" + block + "\n";
256
+ outcomes.push({ id, outcome: "added", newChecksum: hashContent(normalized) });
257
+ }
258
+ }
259
+ return { content: result, sections: outcomes };
260
+ }
261
+ function inspectSections(relativePath, existing, storedChecksums) {
262
+ const sections = parseSections(existing);
263
+ const editedIds = [];
264
+ for (const { id, inner } of sections) {
265
+ const storedKey = `${relativePath}:${id}`;
266
+ const storedHash = storedChecksums[storedKey];
267
+ if (!storedHash) {
268
+ continue;
269
+ }
270
+ if (hashContent(inner) !== storedHash) {
271
+ editedIds.push(id);
272
+ }
273
+ }
274
+ return editedIds;
275
+ }
276
+
166
277
  // src/upgrade.ts
167
- function writeManagedFile(targetDir, relativePath, newContent, storedChecksums) {
278
+ function writeManagedFile(targetDir, relativePath, newContent, storedChecksums, overwrite) {
279
+ if (hasFences(newContent)) {
280
+ return writeSectionedFile(targetDir, relativePath, newContent, storedChecksums, overwrite ?? /* @__PURE__ */ new Set());
281
+ }
168
282
  const fullPath = join3(targetDir, relativePath);
169
283
  const newChecksum = hashContent(newContent);
170
284
  if (!existsSync3(fullPath)) {
@@ -180,9 +294,51 @@ function writeManagedFile(targetDir, relativePath, newContent, storedChecksums)
180
294
  }
181
295
  return { outcome: "skipped", checksum: storedHash };
182
296
  }
297
+ function writeSectionedFile(targetDir, relativePath, newContent, storedChecksums, overwrite) {
298
+ const fullPath = join3(targetDir, relativePath);
299
+ if (!existsSync3(fullPath)) {
300
+ mkdirSync(dirname2(fullPath), { recursive: true });
301
+ writeFileSync(fullPath, newContent, "utf8");
302
+ const sections2 = parseSections(newContent).map((s) => ({
303
+ id: s.id,
304
+ outcome: "added",
305
+ newChecksum: hashContent(s.inner)
306
+ }));
307
+ return { outcome: "added", checksum: hashContent(newContent), sections: sections2 };
308
+ }
309
+ const existingContent = readFileSync4(fullPath, "utf8");
310
+ if (!hasFences(existingContent)) {
311
+ const currentHash = hashFile(fullPath);
312
+ const storedHash = storedChecksums[relativePath];
313
+ if (!storedHash || currentHash === storedHash) {
314
+ writeFileSync(fullPath, newContent, "utf8");
315
+ const sections2 = parseSections(newContent).map((s) => ({
316
+ id: s.id,
317
+ outcome: "added",
318
+ newChecksum: hashContent(s.inner)
319
+ }));
320
+ return { outcome: "updated", checksum: hashContent(newContent), sections: sections2 };
321
+ } else {
322
+ return { outcome: "skipped", checksum: storedHash ?? currentHash, sections: void 0 };
323
+ }
324
+ }
325
+ const newSectionMap = {};
326
+ for (const s of parseSections(newContent)) {
327
+ newSectionMap[s.id] = s.inner;
328
+ }
329
+ const { content: mergedContent, sections } = mergeSections(
330
+ existingContent,
331
+ newSectionMap,
332
+ storedChecksums,
333
+ relativePath,
334
+ overwrite
335
+ );
336
+ writeFileSync(fullPath, mergedContent, "utf8");
337
+ return { outcome: "updated", checksum: hashContent(mergedContent), sections };
338
+ }
183
339
 
184
340
  // src/state.ts
185
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
341
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
186
342
  import { join as join4 } from "path";
187
343
  var STATE_FILENAME = ".patina-state.json";
188
344
  function normalizeChecksums(checksums) {
@@ -195,7 +351,7 @@ function normalizeChecksums(checksums) {
195
351
  function readState(root, profile) {
196
352
  const statePath = join4(root, STATE_FILENAME);
197
353
  if (existsSync4(statePath)) {
198
- const raw = readFileSync4(statePath, "utf8");
354
+ const raw = readFileSync5(statePath, "utf8");
199
355
  let parsed;
200
356
  try {
201
357
  parsed = JSON.parse(raw);
@@ -271,7 +427,11 @@ function profileToVars(profile, liProfileUrl) {
271
427
  CONTENT_DIR: profile.content_dir,
272
428
  EDITOR: profile.editor,
273
429
  LI_PROFILE_URL: liProfileUrl ?? profile.linkedin?.profile_url ?? "",
274
- TODAY: today
430
+ TODAY: today,
431
+ STALENESS_THRESHOLD: (() => {
432
+ const d = Number(profile.staleness_threshold_days ?? 30);
433
+ return String(Number.isFinite(d) && d > 0 ? d : 30);
434
+ })()
275
435
  };
276
436
  }
277
437
  function baseManagedFiles(vars, editor, targetDir) {
@@ -328,18 +488,7 @@ async function scaffold(opts) {
328
488
  created: today,
329
489
  ...modules.includes("linkedin") && liProfileUrl ? { linkedin: { profile_url: liProfileUrl } } : {}
330
490
  };
331
- const vars = {
332
- PATINA_NAME: patinaName,
333
- USER_NAME: userName,
334
- USER_TITLE: title ?? "",
335
- ROLE_DESCRIPTION: roleDescription,
336
- COMPANY_NAME: work.company_name,
337
- COMPANY_DESCRIPTION: work.company_description ?? "",
338
- CONTENT_DIR: contentDir,
339
- EDITOR: editor,
340
- LI_PROFILE_URL: liProfileUrl,
341
- TODAY: today
342
- };
491
+ const vars = profileToVars(tempProfile, liProfileUrl);
343
492
  mkdirSync2(targetDir, { recursive: true });
344
493
  const checksums = {};
345
494
  const managedFiles = [
@@ -351,8 +500,11 @@ async function scaffold(opts) {
351
500
  if (manifestEntry) validateManifestFrontmatter(module, manifestEntry[1]);
352
501
  }
353
502
  for (const [relativePath, content] of managedFiles) {
354
- const { checksum } = writeManagedFile(targetDir, relativePath, content, {});
355
- checksums[relativePath] = checksum;
503
+ const result = writeManagedFile(targetDir, relativePath, content, {});
504
+ checksums[relativePath] = result.checksum;
505
+ for (const s of result.sections ?? []) {
506
+ checksums[`${relativePath}:${s.id}`] = s.newChecksum;
507
+ }
356
508
  }
357
509
  const baseDirs = ["notes", "skills", "posts"];
358
510
  for (const dir of baseDirs) {
@@ -374,7 +526,7 @@ ${STATE_FILENAME}
374
526
  }
375
527
 
376
528
  // src/validate.ts
377
- import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync } from "fs";
529
+ import { existsSync as existsSync5, readFileSync as readFileSync6, readdirSync } from "fs";
378
530
  import { join as join6, relative, sep, basename } from "path";
379
531
  var NOTES = CONTENT_SUBDIRS[0];
380
532
  var SKILLS = CONTENT_SUBDIRS[1];
@@ -446,7 +598,7 @@ function checkSkillNotes(root, profile) {
446
598
  const noteSlugs = new Set(noteFiles.map((f) => basename(f, ".md")));
447
599
  const issues = [];
448
600
  for (const skillFile of listMarkdownFiles(skillsDir)) {
449
- const content = readFileSync5(skillFile, "utf8");
601
+ const content = readFileSync6(skillFile, "utf8");
450
602
  const links = extractWikiLinks(content);
451
603
  for (const { target, line } of links) {
452
604
  if (!noteSlugs.has(target)) {
@@ -473,7 +625,7 @@ function checkWikiLinks(root, profile) {
473
625
  ...listMarkdownFiles(postsDir)
474
626
  ];
475
627
  for (const file of filesToScan) {
476
- const content = readFileSync5(file, "utf8");
628
+ const content = readFileSync6(file, "utf8");
477
629
  const links = extractWikiLinks(content);
478
630
  for (const { target, line } of links) {
479
631
  if (!noteSlugs.has(target)) {
@@ -493,7 +645,7 @@ function checkExclusions(root, profile) {
493
645
  const notesDir = join6(contentDir, NOTES);
494
646
  const exclusionsPath = join6(notesDir, "exclusions.md");
495
647
  if (!existsSync5(exclusionsPath)) return [];
496
- const content = readFileSync5(exclusionsPath, "utf8");
648
+ const content = readFileSync6(exclusionsPath, "utf8");
497
649
  const items = parseExclusions(content);
498
650
  if (items.length === 0) return [];
499
651
  const skillsDir = join6(contentDir, SKILLS);
@@ -505,7 +657,7 @@ function checkExclusions(root, profile) {
505
657
  const issues = [];
506
658
  const seen = /* @__PURE__ */ new Set();
507
659
  for (const file of filesToScan) {
508
- const fileContent = readFileSync5(file, "utf8");
660
+ const fileContent = readFileSync6(file, "utf8");
509
661
  const lines = fileContent.split("\n");
510
662
  for (let i = 0; i < lines.length; i++) {
511
663
  const lineText = lines[i];
@@ -742,6 +894,15 @@ function writeProfile(cwd, profile) {
742
894
  function removeManagedFileIfUnmodified(targetDir, rel, stored) {
743
895
  const fullPath = join7(targetDir, rel);
744
896
  if (!existsSync6(fullPath)) return "deleted";
897
+ const fileContent = readFileSync7(fullPath, "utf8");
898
+ if (hasFences(fileContent)) {
899
+ const editedIds = inspectSections(rel, fileContent, stored);
900
+ if (editedIds.length > 0) {
901
+ return "kept";
902
+ }
903
+ unlinkSync(fullPath);
904
+ return "deleted";
905
+ }
745
906
  const currentHash = hashFile(fullPath);
746
907
  const storedHash = stored[rel];
747
908
  if (storedHash && currentHash !== storedHash) {
@@ -783,7 +944,7 @@ async function runUpdate(cwd) {
783
944
  await runValidate(cwd, profile);
784
945
  }
785
946
  }
786
- function applyProfileUpdate(cwd, profile, fields) {
947
+ function applyProfileUpdate(cwd, profile, fields, overwrite) {
787
948
  const updatedProfile = {
788
949
  ...profile,
789
950
  name: fields.name.trim(),
@@ -806,10 +967,19 @@ function applyProfileUpdate(cwd, profile, fields) {
806
967
  ];
807
968
  const updated = [];
808
969
  const skipped = [];
970
+ const keptSections = [];
809
971
  for (const [rel, content] of files) {
810
- const { outcome, checksum } = writeManagedFile(cwd, rel, content, stored);
811
- newChecksums[rel] = checksum;
812
- if (outcome === "skipped") {
972
+ const result = writeManagedFile(cwd, rel, content, stored, overwrite);
973
+ newChecksums[rel] = result.checksum;
974
+ for (const s of result.sections ?? []) {
975
+ const sKey = `${rel}:${s.id}`;
976
+ if (s.outcome !== "skipped") newChecksums[sKey] = s.newChecksum;
977
+ else {
978
+ newChecksums[sKey] = stored[sKey] ?? "";
979
+ keptSections.push(sKey);
980
+ }
981
+ }
982
+ if (result.outcome === "skipped") {
813
983
  skipped.push(rel);
814
984
  } else {
815
985
  updated.push(rel);
@@ -823,7 +993,7 @@ function applyProfileUpdate(cwd, profile, fields) {
823
993
  writeState(cwd, { checksums: newChecksums });
824
994
  const profileToWrite = stripLegacyChecksums(updatedProfile);
825
995
  writeProfile(cwd, profileToWrite);
826
- return { profile: profileToWrite, updated, skipped };
996
+ return { profile: profileToWrite, updated, skipped, keptSections };
827
997
  }
828
998
  async function runUpdateProfile(cwd, profile) {
829
999
  console.log("");
@@ -876,7 +1046,7 @@ async function runUpdateProfile(cwd, profile) {
876
1046
  },
877
1047
  { onCancel }
878
1048
  );
879
- const { updated, skipped } = applyProfileUpdate(cwd, profile, {
1049
+ const fields = {
880
1050
  name: identity.name,
881
1051
  title: identity.title ?? "",
882
1052
  roleDescription: identity.roleDescription ?? "",
@@ -885,11 +1055,52 @@ async function runUpdateProfile(cwd, profile) {
885
1055
  companyName: work.companyName ?? "",
886
1056
  website: work.website ?? "",
887
1057
  companyDescription: work.companyDescription ?? ""
888
- });
1058
+ };
1059
+ const overwriteSet = /* @__PURE__ */ new Set();
1060
+ const previewProfile = {
1061
+ ...profile,
1062
+ name: fields.name.trim(),
1063
+ title: fields.title.trim(),
1064
+ role_description: fields.roleDescription.trim() || void 0,
1065
+ job_description_url: fields.jobDescriptionUrl.trim() || void 0,
1066
+ work: {
1067
+ self_employed: fields.selfEmployed,
1068
+ company_name: fields.companyName.trim() || (fields.selfEmployed ? "Freelance" : ""),
1069
+ website: fields.website.trim() || void 0,
1070
+ company_description: fields.companyDescription.trim() || void 0
1071
+ }
1072
+ };
1073
+ const previewVars = profileToVars(previewProfile);
1074
+ const storedChecksums = readState(cwd, profile).checksums;
1075
+ const previewFiles = [
1076
+ ...baseManagedFiles(previewVars, previewProfile.editor, cwd),
1077
+ ...previewProfile.modules.flatMap((m) => moduleManagedFiles(m, previewVars))
1078
+ ];
1079
+ for (const [rel, content] of previewFiles) {
1080
+ if (hasFences(content)) {
1081
+ const fullPath = join7(cwd, rel);
1082
+ if (existsSync6(fullPath)) {
1083
+ const existingContent = readFileSync7(fullPath, "utf8");
1084
+ const editedIds = inspectSections(rel, existingContent, storedChecksums);
1085
+ for (const sectionId of editedIds) {
1086
+ const confirmed = await p.confirm({
1087
+ message: `Section '${sectionId}' in ${rel} has been manually edited. Overwrite?`,
1088
+ initialValue: false
1089
+ });
1090
+ if (p.isCancel(confirmed)) onCancel();
1091
+ if (confirmed) overwriteSet.add(sectionId);
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+ const { updated, skipped, keptSections } = applyProfileUpdate(cwd, profile, fields, overwriteSet);
889
1097
  const summaryLines = [];
890
1098
  if (updated.length > 0) {
891
1099
  summaryLines.push(chalk.hex("#94A3B8")(`Updated: ${updated.join(", ")}`));
892
1100
  }
1101
+ if (keptSections.length > 0) {
1102
+ summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${keptSections.join(", ")}`));
1103
+ }
893
1104
  if (skipped.length > 0) {
894
1105
  summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${skipped.join(", ")}`));
895
1106
  }
@@ -904,6 +1115,7 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
904
1115
  const skippedFiles = [];
905
1116
  const deleted = [];
906
1117
  const kept = [];
1118
+ const keptSections = [];
907
1119
  for (const module of toAdd) {
908
1120
  const def = getModule(module);
909
1121
  if (def?.onAdd) {
@@ -912,9 +1124,17 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
912
1124
  const vars = profileToVars(updatedProfile);
913
1125
  const contentDir = updatedProfile.content_dir;
914
1126
  for (const [rel, content] of moduleManagedFiles(module, vars)) {
915
- const { outcome, checksum } = writeManagedFile(cwd, rel, content, newChecksums);
916
- newChecksums[rel] = checksum;
917
- if (outcome === "skipped") {
1127
+ const result = writeManagedFile(cwd, rel, content, newChecksums);
1128
+ newChecksums[rel] = result.checksum;
1129
+ for (const s of result.sections ?? []) {
1130
+ const sKey = `${rel}:${s.id}`;
1131
+ if (s.outcome !== "skipped") newChecksums[sKey] = s.newChecksum;
1132
+ else {
1133
+ newChecksums[sKey] = newChecksums[sKey] ?? "";
1134
+ keptSections.push(sKey);
1135
+ }
1136
+ }
1137
+ if (result.outcome === "skipped") {
918
1138
  skippedFiles.push(rel);
919
1139
  } else {
920
1140
  added.push(rel);
@@ -940,6 +1160,10 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
940
1160
  if (result === "deleted") {
941
1161
  deleted.push(rel);
942
1162
  delete newChecksums[rel];
1163
+ const prefix = rel + ":";
1164
+ for (const key of Object.keys(newChecksums)) {
1165
+ if (key.startsWith(prefix)) delete newChecksums[key];
1166
+ }
943
1167
  } else {
944
1168
  kept.push(rel);
945
1169
  }
@@ -952,7 +1176,7 @@ function applyModuleChanges(cwd, profile, toAdd, toRemove, moduleInputs) {
952
1176
  writeState(cwd, { checksums: newChecksums });
953
1177
  const finalProfile = stripLegacyChecksums(updatedProfile);
954
1178
  writeProfile(cwd, finalProfile);
955
- return { profile: finalProfile, added, skipped: skippedFiles, deleted, kept };
1179
+ return { profile: finalProfile, added, skipped: skippedFiles, deleted, kept, keptSections };
956
1180
  }
957
1181
  async function runUpdateModules(cwd, profile) {
958
1182
  const currentModules = profile.modules ?? [];
@@ -991,9 +1215,10 @@ async function runUpdateModules(cwd, profile) {
991
1215
  liProfileUrl = url.trim();
992
1216
  }
993
1217
  }
994
- const { added: addedFiles, skipped: skippedFiles, deleted: deletedFiles, kept: keptFiles } = applyModuleChanges(cwd, profile, toAdd, toRemove, { linkedin: { liProfileUrl } });
1218
+ const { added: addedFiles, skipped: skippedFiles, deleted: deletedFiles, kept: keptFiles, keptSections: keptSectionKeys } = applyModuleChanges(cwd, profile, toAdd, toRemove, { linkedin: { liProfileUrl } });
995
1219
  const summaryLines = [];
996
1220
  if (addedFiles.length > 0) summaryLines.push(chalk.hex("#94A3B8")(`Added: ${addedFiles.join(", ")}`));
1221
+ if (keptSectionKeys.length > 0) summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${keptSectionKeys.join(", ")}`));
997
1222
  if (skippedFiles.length > 0) summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edits: ${skippedFiles.join(", ")}`));
998
1223
  if (deletedFiles.length > 0) summaryLines.push(chalk.hex("#94A3B8")(`Removed: ${deletedFiles.join(", ")}`));
999
1224
  if (keptFiles.length > 0) summaryLines.push(chalk.hex("#FFAB2E")(`Kept your edited files: ${keptFiles.join(", ")}`));
@@ -1022,7 +1247,7 @@ function registerCommands(_program) {
1022
1247
  // src/cli.ts
1023
1248
  import chalk2 from "chalk";
1024
1249
  var program = new Command();
1025
- program.name("patina").description("Personal professional knowledge graph \u2014 setup and management");
1250
+ program.name("patina").description("Personal professional knowledge graph \u2014 setup and management").allowExcessArguments(true);
1026
1251
  program.command("validate").description("Check your patina for broken links and excluded items").action(() => {
1027
1252
  try {
1028
1253
  const cwd = process.cwd();
@@ -2,6 +2,7 @@
2
2
 
3
3
  This file is loaded automatically each session to give you context about who you're working with and how this patina is organised.
4
4
 
5
+ <!-- patina:profile:start -->
5
6
  ## Who you're working with
6
7
 
7
8
  **Name:** {{USER_NAME}}
@@ -11,6 +12,7 @@ This file is loaded automatically each session to give you context about who you
11
12
  {{ROLE_DESCRIPTION}}
12
13
 
13
14
  {{COMPANY_DESCRIPTION}}
15
+ <!-- patina:profile:end -->
14
16
 
15
17
  ## What patina is
16
18
 
@@ -42,6 +44,25 @@ The graph is the source of truth. Nothing gets added to generated content unless
42
44
  - Never delete skill files automatically. Surface them to the user and wait for confirmation.
43
45
  - `{{CONTENT_DIR}}/notes/exclusions.md` overrides everything. If something is listed there, it must not appear in any generated output.
44
46
 
47
+ ## On session start
48
+
49
+ Before anything else, scan the graph for stale content and surface a brief report.
50
+
51
+ Read the file modification times for all three areas — skip `.gitkeep`, `README.md`, and `exclusions.md` in every directory:
52
+ - `{{CONTENT_DIR}}/notes/`
53
+ - `{{CONTENT_DIR}}/skills/`
54
+ - `{{CONTENT_DIR}}/posts/`
55
+
56
+ List items not modified in the last **{{STALENESS_THRESHOLD}} days**, grouped by area:
57
+
58
+ - **Notes** — stale note slugs
59
+ - **Skills** — stale skill slugs
60
+ - **Posts** — stale draft slugs
61
+
62
+ Skip any area with nothing stale. If everything is fresh, say so in one line. Keep the report brief — one line per area. Then ask:
63
+
64
+ > What are we working on today?
65
+
45
66
  ## Slash commands
46
67
 
47
68
  | Command | What it does |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-patina",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Personal professional knowledge graph — setup and management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@clack/prompts": "^0.9.0",
22
22
  "chalk": "^5.3.0",
23
- "commander": "^12.1.0",
23
+ "commander": "^15.0.0",
24
24
  "js-yaml": "^4.1.0"
25
25
  },
26
26
  "devDependencies": {
@@ -28,8 +28,8 @@
28
28
  "@types/node": "^20.0.0",
29
29
  "tsup": "^8.0.0",
30
30
  "tsx": "^4.0.0",
31
- "typescript": "^5.0.0",
32
- "vitest": "^1.0.0"
31
+ "typescript": "^6.0.3",
32
+ "vitest": "^4.1.7"
33
33
  },
34
34
  "tsup": {
35
35
  "entry": [