threadnote 1.7.0 → 1.7.1

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.
@@ -25,15 +25,16 @@
25
25
  "title": "Consolidate memories under the git remote project name",
26
26
  "description": [
27
27
  "Threadnote now uses the git remote repository name as the default project identifier instead of the clone folder name.",
28
- "This migration scans personal lifecycle-aware memory folders for the old folder-derived project name from the current workspace.",
29
- "It also renames the matching seed manifest project and workset references so future seeded guidance uses the remote-derived project name.",
28
+ "This migration scans personal lifecycle-aware memory project folders and uses repo_path evidence in memories, plus matching seed manifest paths when present, to resolve git remote names.",
29
+ "It covers every detected personal memory project, not just the workspace where threadnote update was run.",
30
+ "It also renames matching seed manifest projects and workset references so future seeded guidance uses the remote-derived project name.",
30
31
  "Matching memories are copied under the remote-derived project name with updated project metadata, then the old copy is removed.",
31
32
  "If a destination file already exists with different content, Threadnote keeps both by writing the migrated copy with a -from-<old-project> suffix.",
32
- "Shared team memories and unrelated project folders are left untouched."
33
+ "Shared team memories and projects without repo path evidence are left untouched."
33
34
  ],
34
35
  "commandArgs": ["migrate-project-names", "--apply"],
35
36
  "instructions": [
36
- "Post-update project-name migration finished. Future default handoffs and memories for this repo will use the git remote repository name as their project.",
37
+ "Post-update project-name migration finished. Future default handoffs and memories for migrated repos will use the git remote repository name as their project.",
37
38
  "If the migration updated your seed manifest, run threadnote seed --only <new-project-name> to re-ingest seeded resources under the new project URI.",
38
39
  "If Threadnote reported any original files were still being processed, rerun the printed threadnote forget <uri> command later."
39
40
  ],
@@ -11633,21 +11633,30 @@ async function runMigrateLifecycle(config, options) {
11633
11633
  async function runMigrateProjectNames(config, options) {
11634
11634
  const dryRun = options.dryRun === true || options.apply !== true;
11635
11635
  const limit = options.limit ? parsePositiveInteger(options.limit, "project-name migration limit") : void 0;
11636
- const context = await projectNameMigrationContext();
11637
- if (!context) {
11638
- console.log("No git remote project-name change applies in the current workspace.");
11636
+ const contexts = await projectNameMigrationContexts(config);
11637
+ if (contexts.length === 0) {
11638
+ console.log("No git remote project-name changes apply across configured projects.");
11639
11639
  return;
11640
11640
  }
11641
- const candidates = await projectNameMigrationCandidates(config, context, limit);
11642
- const seedManifestCandidate = await hasSeedManifestProjectNameMigrationCandidate(config, context);
11643
- if (candidates.length === 0 && !seedManifestCandidate) {
11644
- console.log(`No project-name migration candidates found for ${context.oldProject} -> ${context.newProject}.`);
11641
+ const plans = [];
11642
+ let remaining = limit;
11643
+ for (const context of contexts) {
11644
+ const candidates2 = remaining === 0 ? [] : await projectNameMigrationCandidates(config, context, remaining);
11645
+ plans.push({ candidates: candidates2, context });
11646
+ if (remaining !== void 0) {
11647
+ remaining = Math.max(0, remaining - candidates2.length);
11648
+ }
11649
+ }
11650
+ const seedManifestMigration = await seedManifestProjectNameMigration(config, contexts);
11651
+ if (!plans.some((plan) => plan.candidates.length > 0) && !seedManifestMigration) {
11652
+ console.log("No project-name migration candidates found across configured projects.");
11645
11653
  return;
11646
11654
  }
11647
- const seedManifestUpdated = await migrateSeedManifestProjectName(config, context, dryRun);
11655
+ const seedManifestUpdated = await migrateSeedManifestProjectNames(config, seedManifestMigration, dryRun);
11648
11656
  let existingCount = 0;
11649
11657
  let migratedCount = 0;
11650
11658
  let skippedCount = 0;
11659
+ const candidates = plans.flatMap((plan) => [...plan.candidates]);
11651
11660
  if (candidates.length > 0) {
11652
11661
  const ov = await openVikingCliForMode(dryRun);
11653
11662
  for (const candidate of candidates) {
@@ -11671,37 +11680,186 @@ async function runMigrateProjectNames(config, options) {
11671
11680
  migratedCount += 1;
11672
11681
  }
11673
11682
  }
11683
+ const activeContexts = projectNameMigrationActiveContexts(plans, seedManifestMigration);
11684
+ const newProjectsToSeed = [...new Set(seedManifestMigration?.newProjects ?? [])];
11674
11685
  console.log(
11675
11686
  [
11676
- `Project-name migration summary: ${migratedCount} memor${migratedCount === 1 ? "y" : "ies"} ${dryRun ? "would be migrated" : "migrated"} from ${context.oldProject} to ${context.newProject}`,
11687
+ projectNameMigrationSummary(migratedCount, dryRun, activeContexts),
11677
11688
  seedManifestUpdated ? `seed manifest ${dryRun ? "would be updated" : "updated"}` : "seed manifest unchanged",
11678
11689
  `${existingCount} duplicate destination(s) reused`,
11679
11690
  `${skippedCount} source(s) still processing`,
11680
11691
  dryRun ? "Run with --apply to perform this migration." : void 0,
11681
- seedManifestUpdated ? `Run threadnote seed --only ${context.newProject} to re-ingest seeded resources under the new project URI.` : void 0
11692
+ ...newProjectsToSeed.map(
11693
+ (project) => `Run threadnote seed --only ${project} to re-ingest seeded resources under the new project URI.`
11694
+ )
11682
11695
  ].filter((part) => part !== void 0).join("; ")
11683
11696
  );
11684
11697
  }
11685
11698
  async function hasProjectNameMigrationCandidates(config) {
11686
- const context = await projectNameMigrationContext();
11687
- return context ? (await projectNameMigrationCandidates(config, context, 1)).length > 0 || await hasSeedManifestProjectNameMigrationCandidate(config, context) : false;
11699
+ const contexts = await projectNameMigrationContexts(config);
11700
+ if (contexts.length === 0) {
11701
+ return false;
11702
+ }
11703
+ for (const context of contexts) {
11704
+ if ((await projectNameMigrationCandidates(config, context, 1)).length > 0) {
11705
+ return true;
11706
+ }
11707
+ }
11708
+ return await seedManifestProjectNameMigration(config, contexts) !== void 0;
11709
+ }
11710
+ async function projectNameMigrationContexts(config) {
11711
+ const evidence = await projectNameMigrationMemoryEvidence(config);
11712
+ const contexts = [];
11713
+ let manifest;
11714
+ try {
11715
+ manifest = await readSeedManifest(config.manifestPath);
11716
+ } catch (_err) {
11717
+ manifest = void 0;
11718
+ }
11719
+ if (manifest) {
11720
+ for (const project of manifest.projects) {
11721
+ const projectEvidence = evidence.get(uriSegment(project.name));
11722
+ if (projectEvidence) {
11723
+ projectEvidence.repoPaths.add(expandPath(project.path));
11724
+ }
11725
+ }
11726
+ }
11727
+ for (const projectEvidence of evidence.values()) {
11728
+ for (const repoPath of projectEvidence.repoPaths) {
11729
+ const context = await projectNameMigrationContextForRepoPath(projectEvidence.oldProject, repoPath);
11730
+ if (context) {
11731
+ contexts.push(context);
11732
+ }
11733
+ }
11734
+ }
11735
+ const currentContext = await currentWorkspaceProjectNameMigrationContext(evidence);
11736
+ if (currentContext) {
11737
+ contexts.push(currentContext);
11738
+ }
11739
+ return dedupeProjectNameMigrationContexts(contexts);
11740
+ }
11741
+ async function projectNameMigrationMemoryEvidence(config) {
11742
+ const evidence = /* @__PURE__ */ new Map();
11743
+ for (const location of projectMemoryLocations()) {
11744
+ const locationRoot = (0, import_node_path8.join)(localUserMemoriesRoot(config), ...location.relativePath);
11745
+ let projectEntries;
11746
+ try {
11747
+ projectEntries = await (0, import_promises6.readdir)(locationRoot, { withFileTypes: true });
11748
+ } catch (_err) {
11749
+ continue;
11750
+ }
11751
+ for (const projectEntry of projectEntries) {
11752
+ if (!projectEntry.isDirectory() || projectEntry.name.startsWith(".")) {
11753
+ continue;
11754
+ }
11755
+ const oldSegment = projectEntry.name;
11756
+ const projectEvidence = ensureProjectNameMigrationEvidence(evidence, oldSegment);
11757
+ const projectDirectory = (0, import_node_path8.join)(locationRoot, projectEntry.name);
11758
+ let memoryEntries;
11759
+ try {
11760
+ memoryEntries = await (0, import_promises6.readdir)(projectDirectory, { withFileTypes: true });
11761
+ } catch (_err) {
11762
+ continue;
11763
+ }
11764
+ for (const memoryEntry of memoryEntries) {
11765
+ if (!memoryEntry.isFile() || memoryEntry.name.startsWith(".") || !memoryEntry.name.endsWith(".md")) {
11766
+ continue;
11767
+ }
11768
+ const content = await readTextIfExists((0, import_node_path8.join)(projectDirectory, memoryEntry.name));
11769
+ if (!content) {
11770
+ continue;
11771
+ }
11772
+ const sourceUri = `viking://user/${uriSegment(config.user)}/memories/${location.uriPath}/${oldSegment}/${memoryEntry.name}`;
11773
+ const record = parseMemoryDocument(sourceUri, content);
11774
+ if (record?.metadata.project && uriSegment(record.metadata.project) === oldSegment) {
11775
+ projectEvidence.oldProject = record.metadata.project;
11776
+ }
11777
+ const repoPath = repoPathEvidenceFromMemory(content);
11778
+ if (repoPath) {
11779
+ projectEvidence.repoPaths.add(repoPath);
11780
+ }
11781
+ }
11782
+ }
11783
+ }
11784
+ return evidence;
11785
+ }
11786
+ function ensureProjectNameMigrationEvidence(evidence, oldSegment) {
11787
+ const existing = evidence.get(oldSegment);
11788
+ if (existing) {
11789
+ return existing;
11790
+ }
11791
+ const created = { oldProject: oldSegment, oldSegment, repoPaths: /* @__PURE__ */ new Set() };
11792
+ evidence.set(oldSegment, created);
11793
+ return created;
11794
+ }
11795
+ function repoPathEvidenceFromMemory(content) {
11796
+ const match = /^repo_path:\s*(.+)$/m.exec(content);
11797
+ if (!match?.[1]) {
11798
+ return void 0;
11799
+ }
11800
+ const cleaned = match[1].trim().replace(/^['"`]+|['"`]+$/g, "").replace(/[.,;]+$/g, "");
11801
+ if (!cleaned.startsWith("/") && !cleaned.startsWith("~/")) {
11802
+ return void 0;
11803
+ }
11804
+ return expandPath(cleaned);
11688
11805
  }
11689
- async function projectNameMigrationContext() {
11806
+ async function projectNameMigrationContextForRepoPath(oldProject, repoPath) {
11807
+ const repoRoot = await gitValue(["rev-parse", "--show-toplevel"], repoPath);
11808
+ if (!repoRoot) {
11809
+ return void 0;
11810
+ }
11811
+ const newProject = await resolveGitRemoteRepoName(repoRoot);
11812
+ if (!newProject) {
11813
+ return void 0;
11814
+ }
11815
+ return projectNameMigrationContextFromParts({
11816
+ newProject,
11817
+ oldProject,
11818
+ repoRoot
11819
+ });
11820
+ }
11821
+ async function currentWorkspaceProjectNameMigrationContext(evidence) {
11690
11822
  const repoRoot = await gitValue(["rev-parse", "--show-toplevel"]);
11691
11823
  if (!repoRoot) {
11692
11824
  return void 0;
11693
11825
  }
11694
- const newProject = await resolveRepoName(repoRoot);
11826
+ const newProject = await resolveGitRemoteRepoName(repoRoot);
11695
11827
  const oldProject = await resolveRepoFolderName(repoRoot);
11696
11828
  if (!newProject || !oldProject) {
11697
11829
  return void 0;
11698
11830
  }
11699
- const newSegment = uriSegment(newProject);
11700
11831
  const oldSegment = uriSegment(oldProject);
11832
+ if (!evidence.has(oldSegment)) {
11833
+ return void 0;
11834
+ }
11835
+ return projectNameMigrationContextFromParts({ newProject, oldProject, repoRoot });
11836
+ }
11837
+ function projectNameMigrationContextFromParts(params) {
11838
+ const newSegment = uriSegment(params.newProject);
11839
+ const oldSegment = uriSegment(params.oldProject);
11701
11840
  if (newSegment === oldSegment) {
11702
11841
  return void 0;
11703
11842
  }
11704
- return { newProject, newSegment, oldProject, oldSegment, repoRoot };
11843
+ return {
11844
+ newProject: params.newProject,
11845
+ newSegment,
11846
+ oldProject: params.oldProject,
11847
+ oldSegment,
11848
+ repoRoot: params.repoRoot
11849
+ };
11850
+ }
11851
+ function dedupeProjectNameMigrationContexts(contexts) {
11852
+ const seen = /* @__PURE__ */ new Set();
11853
+ const out = [];
11854
+ for (const context of contexts) {
11855
+ const key = `${context.oldSegment}\0${context.newSegment}\0${context.repoRoot}`;
11856
+ if (seen.has(key)) {
11857
+ continue;
11858
+ }
11859
+ seen.add(key);
11860
+ out.push(context);
11861
+ }
11862
+ return out;
11705
11863
  }
11706
11864
  async function projectNameMigrationCandidates(config, context, limit) {
11707
11865
  const candidates = [];
@@ -11753,11 +11911,23 @@ async function projectNameMigrationCandidates(config, context, limit) {
11753
11911
  }
11754
11912
  return candidates;
11755
11913
  }
11756
- async function hasSeedManifestProjectNameMigrationCandidate(config, context) {
11757
- return await seedManifestProjectNameMigration(config, context) !== void 0;
11914
+ function projectNameMigrationActiveContexts(plans, seedManifestMigration) {
11915
+ return dedupeProjectNameMigrationContexts([
11916
+ ...plans.filter((plan) => plan.candidates.length > 0).map((plan) => plan.context),
11917
+ ...seedManifestMigration?.contexts ?? []
11918
+ ]);
11758
11919
  }
11759
- async function migrateSeedManifestProjectName(config, context, dryRun) {
11760
- const migration = await seedManifestProjectNameMigration(config, context);
11920
+ function projectNameMigrationSummary(migratedCount, dryRun, contexts) {
11921
+ const memoryWord = migratedCount === 1 ? "memory" : "memories";
11922
+ const verb = dryRun ? "would be migrated" : "migrated";
11923
+ if (contexts.length === 1) {
11924
+ const [context] = contexts;
11925
+ return `Project-name migration summary: ${migratedCount} ${memoryWord} ${verb} from ${context.oldProject} to ${context.newProject}`;
11926
+ }
11927
+ const renameSummary = contexts.map((context) => `${context.oldProject} -> ${context.newProject}`).join(", ");
11928
+ return `Project-name migration summary: ${migratedCount} ${memoryWord} ${verb} across ${contexts.length} project rename(s)${renameSummary ? `: ${renameSummary}` : ""}`;
11929
+ }
11930
+ async function migrateSeedManifestProjectNames(config, migration, dryRun) {
11761
11931
  if (!migration) {
11762
11932
  return false;
11763
11933
  }
@@ -11779,33 +11949,45 @@ async function migrateSeedManifestProjectName(config, context, dryRun) {
11779
11949
  console.log(`Updated seed manifest: ${config.manifestPath}`);
11780
11950
  return true;
11781
11951
  }
11782
- async function seedManifestProjectNameMigration(config, context) {
11952
+ async function seedManifestProjectNameMigration(config, contexts) {
11783
11953
  let manifest;
11784
11954
  try {
11785
11955
  manifest = await readSeedManifest(config.manifestPath);
11786
11956
  } catch (_err) {
11787
11957
  return void 0;
11788
11958
  }
11789
- const oldDefaultUri = `viking://resources/repos/${context.oldSegment}`;
11790
- const newDefaultUri = `viking://resources/repos/${context.newSegment}`;
11791
- const newNameExists = manifest.projects.some((project) => uriSegment(project.name) === context.newSegment);
11959
+ const renamed = /* @__PURE__ */ new Map();
11792
11960
  let changed = false;
11793
- let renamedProject = false;
11794
11961
  const projects = manifest.projects.map((project) => {
11795
- if (!isSeedManifestProjectNameCandidate(project, context, oldDefaultUri) || newNameExists) {
11962
+ const context = contexts.find(
11963
+ (candidate) => isSeedManifestProjectNameCandidate(
11964
+ project,
11965
+ candidate,
11966
+ `viking://resources/repos/${candidate.oldSegment}`,
11967
+ `viking://resources/repos/${candidate.newSegment}`
11968
+ )
11969
+ );
11970
+ if (!context) {
11971
+ return project;
11972
+ }
11973
+ const newNameExists = manifest.projects.some(
11974
+ (other) => other !== project && uriSegment(other.name) === context.newSegment
11975
+ );
11976
+ if (newNameExists || [...renamed.values()].some((existing) => existing.newSegment === context.newSegment)) {
11796
11977
  return project;
11797
11978
  }
11798
11979
  changed = true;
11799
- renamedProject = true;
11980
+ renamed.set(context.oldSegment, context);
11800
11981
  return {
11801
11982
  ...project,
11802
11983
  name: context.newProject,
11803
- uri: trimTrailingSlash(project.uri) === oldDefaultUri ? newDefaultUri : project.uri
11984
+ uri: trimTrailingSlash(project.uri) === `viking://resources/repos/${context.oldSegment}` ? `viking://resources/repos/${context.newSegment}` : project.uri
11804
11985
  };
11805
11986
  });
11806
- const worksets = renamedProject ? manifest.worksets?.map((workset) => {
11987
+ const worksets = renamed.size > 0 ? manifest.worksets?.map((workset) => {
11807
11988
  const members = workset.projects.map((projectName) => {
11808
- if (uriSegment(projectName) !== context.oldSegment) {
11989
+ const context = renamed.get(uriSegment(projectName));
11990
+ if (!context) {
11809
11991
  return projectName;
11810
11992
  }
11811
11993
  changed = true;
@@ -11817,6 +11999,8 @@ async function seedManifestProjectNameMigration(config, context) {
11817
11999
  return void 0;
11818
12000
  }
11819
12001
  return {
12002
+ contexts: [...renamed.values()],
12003
+ newProjects: [...new Set([...renamed.values()].map((context) => context.newProject))],
11820
12004
  output: `${index_vite_proxy_tmp_default.dump(
11821
12005
  {
11822
12006
  version: manifest.version,
@@ -11844,11 +12028,17 @@ async function seedManifestProjectNameMigration(config, context) {
11844
12028
  )}`
11845
12029
  };
11846
12030
  }
11847
- function isSeedManifestProjectNameCandidate(project, context, oldDefaultUri) {
11848
- if (uriSegment(project.name) !== context.oldSegment) {
12031
+ function isSeedManifestProjectNameCandidate(project, context, oldDefaultUri, newDefaultUri) {
12032
+ const nameSegment = uriSegment(project.name);
12033
+ const uriMatchesOld = trimTrailingSlash(project.uri) === oldDefaultUri;
12034
+ const pathMatchesRepo = expandPath(project.path) === context.repoRoot;
12035
+ if (nameSegment === context.newSegment && !uriMatchesOld) {
12036
+ return false;
12037
+ }
12038
+ if (nameSegment !== context.oldSegment && !uriMatchesOld && !pathMatchesRepo) {
11849
12039
  return false;
11850
12040
  }
11851
- return trimTrailingSlash(project.uri) === oldDefaultUri || expandPath(project.path) === context.repoRoot;
12041
+ return nameSegment !== context.newSegment || uriMatchesOld || trimTrailingSlash(project.uri) !== newDefaultUri;
11852
12042
  }
11853
12043
  function canMigrateProjectName(record, context) {
11854
12044
  const projectSegment = record.metadata.project ? uriSegment(record.metadata.project) : context.oldSegment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadnote",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "type": "commonjs",
5
5
  "main": "dist/threadnote.cjs",
6
6
  "description": "Shared local context and handoffs for development agents",