threadnote 1.1.6 → 1.3.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.
@@ -36193,6 +36193,7 @@ function grepUrisFromJson(output) {
36193
36193
  }
36194
36194
  }
36195
36195
  var RECALL_CATEGORY_ORDER = ["memories", "resources", "skills"];
36196
+ var RECALL_PROMOTED_EXACT_SCORE = 0;
36196
36197
  function recallSnippet(value) {
36197
36198
  if (typeof value !== "string") {
36198
36199
  return "";
@@ -36244,11 +36245,14 @@ function parseRecallHits(output, options = {}) {
36244
36245
  return [];
36245
36246
  }
36246
36247
  }
36248
+ function stripAnchor(uri) {
36249
+ return uri.replace(/#.*$/, "");
36250
+ }
36247
36251
  function mergeRecallHits(passes) {
36248
36252
  const byDocument = /* @__PURE__ */ new Map();
36249
36253
  for (const pass of passes) {
36250
36254
  for (const hit of pass) {
36251
- const documentUri = hit.uri.replace(/#.*$/, "");
36255
+ const documentUri = stripAnchor(hit.uri);
36252
36256
  const existing = byDocument.get(documentUri);
36253
36257
  if (!existing || hit.score > existing.score) {
36254
36258
  byDocument.set(documentUri, { ...hit, uri: documentUri });
@@ -36256,23 +36260,120 @@ function mergeRecallHits(passes) {
36256
36260
  }
36257
36261
  }
36258
36262
  return [...byDocument.values()].sort(
36259
- (left, right) => RECALL_CATEGORY_ORDER.indexOf(left.category) - RECALL_CATEGORY_ORDER.indexOf(right.category) || right.score - left.score
36263
+ (left, right) => recallCategoryRank(left.category) - recallCategoryRank(right.category) || right.score - left.score
36264
+ );
36265
+ }
36266
+ function recallCategoryRank(category) {
36267
+ const index = RECALL_CATEGORY_ORDER.indexOf(category);
36268
+ return index === -1 ? RECALL_CATEGORY_ORDER.length : index;
36269
+ }
36270
+ function dedupeByContent(hits) {
36271
+ const seen = /* @__PURE__ */ new Set();
36272
+ const kept = [];
36273
+ for (const hit of hits) {
36274
+ if (hit.category !== "memories" && hit.snippet.length > 0) {
36275
+ const key = `${hit.category}
36276
+ ${hit.snippet}`;
36277
+ if (seen.has(key)) {
36278
+ continue;
36279
+ }
36280
+ seen.add(key);
36281
+ }
36282
+ kept.push(hit);
36283
+ }
36284
+ return kept;
36285
+ }
36286
+ function categoryForUri(uri) {
36287
+ if (uri.includes("/memories/")) {
36288
+ return "memories";
36289
+ }
36290
+ if (uri.startsWith("viking://resources/agent-skills/")) {
36291
+ return "skills";
36292
+ }
36293
+ return "resources";
36294
+ }
36295
+ function contextTypeForCategory(category) {
36296
+ if (category === "memories") {
36297
+ return "memory";
36298
+ }
36299
+ return category === "skills" ? "skill" : "resource";
36300
+ }
36301
+ function applyExactMatchBoost(hits, exactMatches) {
36302
+ if (exactMatches.length === 0) {
36303
+ return hits;
36304
+ }
36305
+ const termsByUri = new Map(exactMatches.map((match) => [stripAnchor(match.uri), match.terms]));
36306
+ const annotated = hits.map((hit) => {
36307
+ const terms = termsByUri.get(stripAnchor(hit.uri));
36308
+ return terms ? { ...hit, exactTerms: terms } : hit;
36309
+ });
36310
+ const present = new Set(annotated.map((hit) => stripAnchor(hit.uri)));
36311
+ const promoted = [...termsByUri.keys()].filter((uri) => !present.has(uri)).map((uri) => {
36312
+ const category = categoryForUri(uri);
36313
+ return {
36314
+ category,
36315
+ contextType: contextTypeForCategory(category),
36316
+ exactTerms: termsByUri.get(uri) ?? [],
36317
+ score: RECALL_PROMOTED_EXACT_SCORE,
36318
+ snippet: "",
36319
+ uri
36320
+ };
36321
+ });
36322
+ return [...annotated, ...promoted].sort(
36323
+ (left, right) => recallCategoryRank(left.category) - recallCategoryRank(right.category) || (right.exactTerms?.length ?? 0) - (left.exactTerms?.length ?? 0) || right.score - left.score
36260
36324
  );
36261
36325
  }
36262
- function formatRecallHits(hits, maxHits) {
36263
- if (hits.length === 0) {
36326
+ function renderRecallHits(shown, overflow) {
36327
+ if (shown.length === 0) {
36264
36328
  return void 0;
36265
36329
  }
36266
- const shown = hits.slice(0, maxHits);
36267
36330
  const lines = shown.flatMap((hit, index) => {
36268
- const head = `${index + 1}. ${hit.contextType} \xB7 score ${hit.score.toFixed(2)} \xB7 ${hit.uri}`;
36331
+ const scorePart = hit.score > 0 ? `score ${hit.score.toFixed(2)}` : void 0;
36332
+ const exactPart = hit.exactTerms?.length ? `exact: ${hit.exactTerms.join(", ")}` : void 0;
36333
+ const head = `${index + 1}. ${[hit.contextType, scorePart, exactPart].filter(Boolean).join(" \xB7 ")} \xB7 ${hit.uri}`;
36269
36334
  return hit.snippet ? [head, ` ${hit.snippet}`] : [head];
36270
36335
  });
36271
- if (hits.length > maxHits) {
36272
- lines.push(`(+${hits.length - maxHits} more \u2014 refine the query or read a URI above)`);
36336
+ if (overflow > 0) {
36337
+ lines.push(`(+${overflow} more \u2014 refine the query or read a URI above)`);
36273
36338
  }
36274
36339
  return lines.join("\n");
36275
36340
  }
36341
+ var RECALL_CATEGORY_RESERVE = 2;
36342
+ function selectShownHits(ranked, limit, reserve) {
36343
+ if (ranked.length <= limit) {
36344
+ return ranked;
36345
+ }
36346
+ const selected = /* @__PURE__ */ new Set();
36347
+ for (const category of RECALL_CATEGORY_ORDER) {
36348
+ let taken = 0;
36349
+ for (const hit of ranked) {
36350
+ if (selected.size >= limit || taken >= reserve) {
36351
+ break;
36352
+ }
36353
+ if (hit.category === category && !selected.has(hit.uri)) {
36354
+ selected.add(hit.uri);
36355
+ taken += 1;
36356
+ }
36357
+ }
36358
+ }
36359
+ for (const hit of ranked) {
36360
+ if (selected.size >= limit) {
36361
+ break;
36362
+ }
36363
+ selected.add(hit.uri);
36364
+ }
36365
+ return ranked.filter((hit) => selected.has(hit.uri));
36366
+ }
36367
+ function buildRecallSections(passes, exactMatches, limit) {
36368
+ const ranked = dedupeByContent(applyExactMatchBoost(mergeRecallHits(passes), exactMatches));
36369
+ const shown = selectShownHits(ranked, limit, RECALL_CATEGORY_RESERVE);
36370
+ const shownUris = new Set(shown.map((hit) => stripAnchor(hit.uri)));
36371
+ return {
36372
+ exactTail: formatExactMatchPointers(exactMatches.filter((match) => !shownUris.has(stripAnchor(match.uri)))),
36373
+ ranked,
36374
+ semanticSection: renderRecallHits(shown, ranked.length - shown.length)
36375
+ };
36376
+ }
36276
36377
  function exactMemoryScopeUris(params) {
36277
36378
  const { agentMemoriesUri, includeArchived, intents, projectName, projectResourceUri, userBase } = params;
36278
36379
  const durable = projectName ? `${userBase}/durable/projects/${projectName}` : `${userBase}/durable/projects`;
@@ -36719,835 +36820,1126 @@ function indexRepairWarning(uri, result) {
36719
36820
  return `${uri}: ${result.stderr.trim() || result.stdout.trim() || "reindex failed"}`;
36720
36821
  }
36721
36822
 
36722
- // src/memory_hygiene.ts
36823
+ // src/share.ts
36824
+ var import_promises4 = require("node:fs/promises");
36825
+ var import_node_os2 = require("node:os");
36723
36826
  var import_node_path3 = require("node:path");
36724
- var HYGIENE_SOURCES_HEADING = "## Threadnote Hygiene Sources";
36725
- var STALE_HANDOFF_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
36726
- function parseMemoryDocument(uri, content) {
36727
- const trimmed = content.trim();
36728
- if (!trimmed) {
36729
- return void 0;
36730
- }
36731
- const separatorIndex = trimmed.indexOf("\n\n");
36732
- const header = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
36733
- const body = separatorIndex === -1 ? "" : trimmed.slice(separatorIndex + 2).trim();
36734
- const firstLine = header.split("\n")[0]?.trim();
36735
- if (firstLine !== "MEMORY" && firstLine !== "HANDOFF") {
36736
- return void 0;
36737
- }
36738
- const kind = parseOptionalMemoryKind(headerValue(header, "kind")) ?? (firstLine === "HANDOFF" ? "handoff" : void 0);
36739
- const status = parseOptionalMemoryStatus(headerValue(header, "status")) ?? "active";
36740
- if (!kind) {
36741
- return void 0;
36827
+ var import_node_util = require("node:util");
36828
+ var TEAMS_FILE_VERSION = 1;
36829
+ var SHARED_SEGMENT = "shared";
36830
+ var SHAREABLE_MEMORY_KIND_DIRS = ["durable"];
36831
+ var SHAREABLE_ARTIFACT_DIR = "agent-artifacts";
36832
+ var SHAREABLE_TOP_LEVEL_DIRS = [...SHAREABLE_MEMORY_KIND_DIRS, SHAREABLE_ARTIFACT_DIR];
36833
+ var ARTIFACT_INSTALL_METADATA_VERSION = 1;
36834
+ var BUNDLE_MANIFEST_VERSION = 1;
36835
+ var BUNDLE_MANIFEST_FILE = ".threadnote-bundle.json";
36836
+ var BUNDLE_INSTALL_METADATA_FILE = ".threadnote-bundle-install.json";
36837
+ var OV_SUMMARY_FILES = [".abstract.md", ".overview.md"];
36838
+ var BUNDLE_IGNORE_DIR_NAMES = [".git", "node_modules", "reviews", "repos"];
36839
+ var PACK_INDEX_SUFFIX = ".pack.md";
36840
+ var PACK_MANIFEST_SUFFIX = ".pack.json";
36841
+ var PACK_FILES_DIR = "files";
36842
+ var PACK_ROOT_TOKEN = "${THREADNOTE_PACK_ROOT}";
36843
+ var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
36844
+ var DEFAULT_GIT_REMOTE_NAME = "origin";
36845
+ var SCRUBBER_PATTERNS = [
36846
+ // Credentials: never redactable. Blocking is the only safe response —
36847
+ // automated redaction risks false negatives that leave material in git.
36848
+ { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
36849
+ { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
36850
+ { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
36851
+ { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
36852
+ { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
36853
+ { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
36854
+ // Matches bare JWTs (three base64url segments). May surface a JWE token in
36855
+ // legitimate docs; if that becomes noisy we can switch to warn-only.
36856
+ { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
36857
+ { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
36858
+ // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
36859
+ // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
36860
+ { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
36861
+ // Soft leaks: block by default (so the agent sees them and decides), but
36862
+ // allow opt-in redaction so curated memories with incidental matches can
36863
+ // ship without a manual rewrite. Local home paths are the recurring
36864
+ // real-world leak; the regexes greedily consume the whole path segment
36865
+ // (including subdirectories) up to whitespace or common closing punctuation
36866
+ // so redaction collapses an entire path to a single placeholder rather than
36867
+ // leaving the subpath visible.
36868
+ { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
36869
+ // No leading \b: it only matches when a word char precedes the slash, which
36870
+ // misses the common cases (after =, :, whitespace, or line start). Mirror the
36871
+ // macOS pattern so /home/... is caught in every position.
36872
+ { name: "linux home path", placeholder: "<local-path>", regex: /\/home\/[^\s)>"'`,]+/ }
36873
+ ];
36874
+ function applyScrubber(content, { redact }) {
36875
+ let cleaned = content;
36876
+ const redactions = [];
36877
+ for (const pattern of SCRUBBER_PATTERNS) {
36878
+ if (!pattern.regex.test(cleaned)) {
36879
+ continue;
36880
+ }
36881
+ if (!pattern.placeholder || !redact) {
36882
+ return { blocker: pattern.name, cleaned: content, redactions: [] };
36883
+ }
36884
+ const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
36885
+ const globalRegex = new RegExp(pattern.regex.source, flags);
36886
+ const matches = cleaned.match(globalRegex) ?? [];
36887
+ cleaned = cleaned.replace(globalRegex, pattern.placeholder);
36888
+ redactions.push({ count: matches.length, name: pattern.name });
36742
36889
  }
36743
- return {
36744
- body,
36745
- content: trimmed,
36746
- headerTitle: firstLine,
36747
- metadata: {
36748
- archivedFrom: headerValue(header, "archived_from"),
36749
- kind,
36750
- project: normalizeOptionalMetadata(headerValue(header, "project") ?? headerValue(header, "repo")),
36751
- sourceAgentClient: headerValue(header, "source_agent_client") ?? "unknown",
36752
- status,
36753
- supersedes: headerValue(header, "supersedes"),
36754
- timestamp: headerValue(header, "timestamp") ?? (/* @__PURE__ */ new Date(0)).toISOString(),
36755
- topic: normalizeOptionalMetadata(headerValue(header, "topic"))
36756
- },
36757
- uri
36758
- };
36890
+ return { cleaned, redactions };
36759
36891
  }
36760
- function buildCompactPlan(records, options) {
36761
- const now = options.now ?? /* @__PURE__ */ new Date();
36762
- const groupedRecords = records.map((record2) => groupableRecord(record2)).filter((item) => item !== void 0).filter((item) => item.project === options.project).filter((item) => options.topic === void 0 || item.topic === options.topic).filter((item) => options.kind === void 0 || item.record.metadata.kind === options.kind);
36763
- const groups = /* @__PURE__ */ new Map();
36764
- for (const item of groupedRecords) {
36765
- groups.set(item.groupKey, [...groups.get(item.groupKey) ?? [], item]);
36892
+ var autoShareStates = /* @__PURE__ */ new Map();
36893
+ function startShareBackgroundFetch(config2) {
36894
+ const state = autoShareState(config2);
36895
+ if (state.timer) {
36896
+ return;
36766
36897
  }
36767
- const keepUpdates = [];
36768
- const archives = [];
36769
- const forgets = [];
36770
- const manualReview = [];
36771
- for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
36772
- const recordsInGroup = group.map((item) => item.record);
36773
- const kind = recordsInGroup[0]?.metadata.kind;
36774
- const topic = group[0]?.topic;
36775
- const project = group[0]?.project;
36776
- if (!kind || !project || !isCompactableKind(kind)) {
36777
- continue;
36898
+ void refreshShareUpdateState(config2, { force: true }).catch((err) => {
36899
+ console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
36900
+ });
36901
+ state.timer = setInterval(() => {
36902
+ void refreshShareUpdateState(config2, { force: false }).catch((err) => {
36903
+ console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
36904
+ });
36905
+ }, AUTO_SHARE_FETCH_INTERVAL_MS);
36906
+ state.timer.unref?.();
36907
+ }
36908
+ async function syncSharedReposBeforeAgentRead(config2) {
36909
+ const state = autoShareState(config2);
36910
+ return enqueueShareOperation(state, async () => {
36911
+ await loadPendingReindexes(config2, state);
36912
+ const warnings = await refreshShareUpdateStateLocked(config2, state, { force: false });
36913
+ const syncTeams = /* @__PURE__ */ new Set([...state.behindTeams, ...state.pendingReindexes.keys()]);
36914
+ if (syncTeams.size === 0) {
36915
+ return { syncedTeams: [], warnings };
36778
36916
  }
36779
- const duplicateGroups = groupBy(recordsInGroup, (record2) => comparableMemoryBody(record2.body));
36780
- const duplicateForgetUris = /* @__PURE__ */ new Set();
36781
- const distinctBodyCount = duplicateGroups.size;
36782
- for (const duplicateGroup of duplicateGroups.values()) {
36783
- if (duplicateGroup.length < 2) {
36784
- continue;
36785
- }
36786
- const duplicateKeep = preferredKeepRecord(duplicateGroup, topic);
36787
- for (const duplicate of sortedNewestFirst(duplicateGroup).filter((record2) => record2.uri !== duplicateKeep.uri)) {
36788
- duplicateForgetUris.add(duplicate.uri);
36789
- forgets.push({ reason: `exact duplicate of ${duplicateKeep.uri}`, uri: duplicate.uri });
36790
- }
36791
- if (distinctBodyCount === 1 || kind !== "handoff") {
36792
- keepUpdates.push({
36793
- content: memoryContentWithHygieneSources(
36794
- duplicateKeep,
36795
- duplicateGroup.map((record2) => record2.uri)
36796
- ),
36797
- reason: "keep exact duplicate group with source URIs",
36798
- sourceUris: duplicateGroup.map((record2) => record2.uri),
36799
- uri: duplicateKeep.uri
36800
- });
36917
+ const syncedTeams = [];
36918
+ const remainingBehind = new Set(state.behindTeams);
36919
+ for (const team of syncTeams) {
36920
+ try {
36921
+ const warning2 = await runShareSyncQuiet(config2, state, { team });
36922
+ if (warning2) {
36923
+ warnings.push(warning2);
36924
+ } else {
36925
+ remainingBehind.delete(team);
36926
+ syncedTeams.push(team);
36927
+ }
36928
+ } catch (err) {
36929
+ warnings.push(
36930
+ `Auto-sync for shared team "${team}" failed: ${err instanceof Error ? err.message : String(err)}`
36931
+ );
36801
36932
  }
36802
36933
  }
36803
- const remainingRecords = recordsInGroup.filter((record2) => !duplicateForgetUris.has(record2.uri));
36804
- if (recordsInGroup.length > 1 && distinctBodyCount === 1) {
36805
- continue;
36806
- }
36807
- if (remainingRecords.length === 0) {
36934
+ state.behindTeams = remainingBehind;
36935
+ state.lastCheckedAt = Date.now();
36936
+ return { syncedTeams, warnings };
36937
+ });
36938
+ }
36939
+ function autoShareState(config2) {
36940
+ const key = `${config2.agentContextHome}:${config2.account}:${config2.user}`;
36941
+ let state = autoShareStates.get(key);
36942
+ if (!state) {
36943
+ state = { behindTeams: /* @__PURE__ */ new Set(), lastCheckedAt: 0, pendingReindexes: /* @__PURE__ */ new Map() };
36944
+ autoShareStates.set(key, state);
36945
+ }
36946
+ return state;
36947
+ }
36948
+ function pendingReindexesPath(config2) {
36949
+ return (0, import_node_path3.join)(config2.agentContextHome, "share", "auto-sync-pending-reindexes.json");
36950
+ }
36951
+ async function loadPendingReindexes(config2, state) {
36952
+ const raw = await readFileIfExists(pendingReindexesPath(config2));
36953
+ if (!raw) {
36954
+ state.pendingReindexes = /* @__PURE__ */ new Map();
36955
+ return;
36956
+ }
36957
+ const parsed = parseJsonConfigObject(raw);
36958
+ if (!parsed || typeof parsed.teams !== "object" || parsed.teams === null || Array.isArray(parsed.teams)) {
36959
+ state.pendingReindexes = /* @__PURE__ */ new Map();
36960
+ return;
36961
+ }
36962
+ const pending = /* @__PURE__ */ new Map();
36963
+ for (const [team, value] of Object.entries(parsed.teams)) {
36964
+ if (!Array.isArray(value)) {
36808
36965
  continue;
36809
36966
  }
36810
- if (remainingRecords.length === 1) {
36811
- const [record2] = remainingRecords;
36812
- if (!record2) {
36967
+ const changes = [];
36968
+ for (const item of value) {
36969
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
36813
36970
  continue;
36814
36971
  }
36815
- if (record2.metadata.supersedes === record2.uri) {
36816
- keepUpdates.push({
36817
- content: memoryContentWithHygieneSources(record2, [record2.uri]),
36818
- reason: "strip self-supersedes header",
36819
- sourceUris: [record2.uri],
36820
- uri: record2.uri
36821
- });
36822
- }
36823
- if (isStaleLookingHandoff(record2, now)) {
36824
- manualReview.push({ reason: "stale-looking active handoff", uri: record2.uri });
36972
+ const entry = item;
36973
+ if (typeof entry.path === "string" && typeof entry.relativePath === "string" && (entry.status === "added" || entry.status === "removed" || entry.status === "modified")) {
36974
+ changes.push({ path: entry.path, relativePath: entry.relativePath, status: entry.status });
36825
36975
  }
36826
- continue;
36827
36976
  }
36828
- if (kind === "handoff") {
36829
- const keep = preferredKeepRecord(remainingRecords, topic);
36830
- const sourceUris = recordsInGroup.map((record2) => record2.uri);
36831
- keepUpdates.push({
36832
- content: memoryContentWithHygieneSources(keep, sourceUris),
36833
- reason: "keep latest handoff and preserve source URIs",
36834
- sourceUris,
36835
- uri: keep.uri
36836
- });
36837
- for (const record2 of sortedNewestFirst(remainingRecords).filter((item) => item.uri !== keep.uri)) {
36838
- archives.push({
36839
- kind,
36840
- project,
36841
- reason: `older handoff for ${project}/${topic ?? "unknown"}`,
36842
- topic,
36843
- uri: record2.uri
36844
- });
36845
- }
36846
- continue;
36847
- }
36848
- for (const record2 of sortedNewestFirst(remainingRecords)) {
36849
- manualReview.push({ reason: `non-exact ${kind} memory in overlapping group`, uri: record2.uri });
36977
+ if (changes.length > 0) {
36978
+ pending.set(team, changes);
36850
36979
  }
36851
36980
  }
36852
- return {
36853
- archives: dedupeByUri(archives),
36854
- forgets: dedupeByUri(forgets),
36855
- keepUpdates: dedupeByUri(keepUpdates),
36856
- manualReview: dedupeByUri(manualReview),
36857
- options,
36858
- recordsScanned: groupedRecords.length
36859
- };
36981
+ state.pendingReindexes = pending;
36860
36982
  }
36861
- function memoryContentWithHygieneSources(record2, sourceUris) {
36862
- const body = stripHygieneSources(record2.body);
36863
- const uniqueSourceUris = [...new Set(sourceUris)].sort();
36864
- const metadata = {
36865
- ...record2.metadata,
36866
- supersedes: record2.metadata.supersedes === record2.uri ? void 0 : record2.metadata.supersedes
36983
+ async function writePendingReindexes(config2, state) {
36984
+ const path = pendingReindexesPath(config2);
36985
+ if (state.pendingReindexes.size === 0) {
36986
+ await (0, import_promises4.rm)(path, { force: true });
36987
+ return;
36988
+ }
36989
+ const contents = {
36990
+ teams: Object.fromEntries(state.pendingReindexes),
36991
+ version: 1
36867
36992
  };
36868
- return formatMemoryDocument(
36869
- record2.headerTitle,
36870
- metadata,
36871
- [body, "", HYGIENE_SOURCES_HEADING, "", ...uniqueSourceUris.map((uri) => `- ${uri}`)].join("\n")
36872
- );
36993
+ await (0, import_promises4.mkdir)((0, import_node_path3.dirname)(path), { recursive: true });
36994
+ const tempPath = `${path}.${process.pid}.tmp`;
36995
+ await (0, import_promises4.writeFile)(tempPath, `${JSON.stringify(contents, void 0, 2)}
36996
+ `, { encoding: "utf8", mode: 384 });
36997
+ await (0, import_promises4.rename)(tempPath, path);
36873
36998
  }
36874
- function formatCompactPlan(plan, options) {
36875
- const scope = [
36876
- `project ${plan.options.project}`,
36877
- plan.options.topic ? `topic ${plan.options.topic}` : void 0,
36878
- plan.options.kind ? `kind ${plan.options.kind}` : void 0
36879
- ].filter((item) => item !== void 0).join(", ");
36880
- const lines = [
36881
- `${options.apply ? "Applying" : "Dry-run"} memory hygiene plan for ${scope}`,
36882
- `Records scanned: ${plan.recordsScanned}`,
36883
- "",
36884
- formatPlanSection(
36885
- "Keep/update",
36886
- plan.keepUpdates.map((action) => `${action.uri} (${action.reason}; sources: ${action.sourceUris.length})`)
36887
- ),
36888
- formatPlanSection(
36889
- "Archive old handoffs",
36890
- plan.archives.map((action) => `${action.uri} (${action.reason})`)
36891
- ),
36892
- formatPlanSection(
36893
- "Forget exact duplicates",
36894
- plan.forgets.map((action) => `${action.uri} (${action.reason})`)
36895
- ),
36896
- formatPlanSection(
36897
- "Manual review",
36898
- plan.manualReview.map((item) => `${item.uri} (${item.reason})`)
36899
- )
36900
- ];
36901
- if (!options.apply) {
36902
- lines.push("", "No changes made. Re-run with --apply to execute this plan.");
36999
+ async function refreshShareUpdateState(config2, options) {
37000
+ const state = autoShareState(config2);
37001
+ const warnings = await enqueueShareOperation(
37002
+ state,
37003
+ async () => refreshShareUpdateStateLocked(config2, state, options)
37004
+ );
37005
+ for (const warning2 of warnings) {
37006
+ console.error(warning2);
36903
37007
  }
36904
- return lines.join("\n");
36905
37008
  }
36906
- function recallHygieneNudges(text, options) {
36907
- const activeUris = activePersonalMemoryUrisFromText(text, options.user);
36908
- const nudges = [];
36909
- const returnedUriSet = new Set(activeUris);
36910
- const returnedRecords = options.records?.filter((record2) => returnedUriSet.has(record2.uri)).map((record2) => groupableRecord(record2)) ?? [];
36911
- const groups = /* @__PURE__ */ new Map();
36912
- for (const item of returnedRecords) {
36913
- if (!item) {
36914
- continue;
36915
- }
36916
- groups.set(item.groupKey, [...groups.get(item.groupKey) ?? [], item]);
37009
+ async function refreshShareUpdateStateLocked(config2, state, options) {
37010
+ const now = Date.now();
37011
+ if (!options.force && state.lastCheckedAt > 0 && now - state.lastCheckedAt < AUTO_SHARE_FETCH_INTERVAL_MS) {
37012
+ return [];
36917
37013
  }
36918
- for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
36919
- if (group.length < 3) {
36920
- continue;
36921
- }
36922
- const first = group[0];
36923
- if (!first || !isCompactableKind(first.record.metadata.kind)) {
36924
- continue;
36925
- }
36926
- const topic = first.topic;
36927
- if (!topic) {
36928
- continue;
37014
+ try {
37015
+ const statuses = await fetchShareUpdateStatuses(config2);
37016
+ const nextBehindTeams = new Set(state.behindTeams);
37017
+ for (const status of statuses) {
37018
+ if (status.warning) {
37019
+ continue;
37020
+ }
37021
+ if (status.behind > 0) {
37022
+ nextBehindTeams.add(status.team);
37023
+ } else {
37024
+ nextBehindTeams.delete(status.team);
37025
+ }
36929
37026
  }
36930
- nudges.push(
36931
- `${group.length} active ${memoryKindPlural(first.record.metadata.kind)} look overlapping for ${first.project}/${topic}; run compact_context({"project":"${first.project}","topic":"${topic}","dryRun":true}).`
36932
- );
37027
+ state.behindTeams = nextBehindTeams;
37028
+ return statuses.flatMap((status) => status.warning ? [status.warning] : []);
37029
+ } finally {
37030
+ state.lastCheckedAt = Date.now();
36933
37031
  }
36934
- const projectCounts = /* @__PURE__ */ new Map();
36935
- for (const uri of activeUris) {
36936
- const parsed = parsePersonalMemoryUri(uri, options.user);
36937
- if (!parsed || parsed.kind !== "handoff" || parsed.status !== "active") {
36938
- continue;
36939
- }
36940
- projectCounts.set(parsed.project, (projectCounts.get(parsed.project) ?? 0) + 1);
37032
+ }
37033
+ function enqueueShareOperation(state, action) {
37034
+ const previous = state.operationPromise ?? Promise.resolve();
37035
+ const current = previous.catch(() => void 0).then(action);
37036
+ state.operationPromise = current.catch(() => void 0);
37037
+ return current;
37038
+ }
37039
+ async function fetchShareUpdateStatuses(config2) {
37040
+ const teamsFile = await readTeamsFile(config2);
37041
+ const teams = Object.entries(teamsFile.teams);
37042
+ if (teams.length === 0) {
37043
+ return [];
36941
37044
  }
36942
- for (const [project, count] of [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right))) {
36943
- if (count < 10) {
37045
+ const git = await requiredExecutable("git");
37046
+ const statuses = [];
37047
+ for (const [name, team] of teams) {
37048
+ const fetchResult = await runCommand(git, ["-C", team.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
37049
+ allowFailure: true
37050
+ });
37051
+ if (fetchResult.exitCode !== 0) {
37052
+ statuses.push({
37053
+ behind: 0,
37054
+ team: name,
37055
+ warning: `Auto-sync check for shared team "${name}" failed: ${fetchResult.stderr.trim() || fetchResult.stdout.trim() || "unknown git fetch error"}`
37056
+ });
36944
37057
  continue;
36945
37058
  }
36946
- nudges.push(
36947
- `Many active handoffs surfaced for ${project}; run compact_context({"project":"${project}","dryRun":true}).`
36948
- );
36949
- }
36950
- return [...new Set(nudges)];
36951
- }
36952
- function activePersonalMemoryUrisFromText(text, user) {
36953
- const userSegment = uriSegment(user);
36954
- const matches = text.matchAll(/viking:\/\/[^\s)]+/g);
36955
- const uris = [];
36956
- for (const match of matches) {
36957
- const uri = match[0]?.replace(/[.,;:]+$/g, "");
36958
- if (!uri || !parsePersonalMemoryUri(uri, userSegment)) {
37059
+ const behind = await gitOutput(team.worktree, ["rev-list", "--count", "HEAD..@{u}"], false);
37060
+ if (behind === void 0) {
37061
+ statuses.push({
37062
+ behind: 0,
37063
+ team: name,
37064
+ warning: `Auto-sync check for shared team "${name}" failed: could not read upstream behind count.`
37065
+ });
36959
37066
  continue;
36960
37067
  }
36961
- uris.push(uri);
37068
+ statuses.push({ behind: Number.parseInt(behind, 10) || 0, team: name });
36962
37069
  }
36963
- return [...new Set(uris)];
37070
+ return statuses;
36964
37071
  }
36965
- function parsePersonalMemoryUri(uri, user) {
36966
- const prefix = `viking://user/${uriSegment(user)}/memories/`;
36967
- if (!uri.startsWith(prefix) || uri.includes("/shared/")) {
36968
- return void 0;
37072
+ async function runShareSyncQuiet(config2, state, options) {
37073
+ const team = await resolveTeam(config2, options.team);
37074
+ const git = await requiredExecutable("git");
37075
+ const worktree = team.config.worktree;
37076
+ const pendingChanges = state.pendingReindexes.get(team.name);
37077
+ if (pendingChanges && pendingChanges.length > 0) {
37078
+ await applyAndPersistChanges(config2, team.config, state, pendingChanges, { quiet: true });
36969
37079
  }
36970
- const rest = uri.slice(prefix.length);
36971
- const parts = rest.split("/").filter(Boolean);
36972
- if (parts.length < 4) {
36973
- return void 0;
37080
+ if (await hasUncommittedChanges(worktree)) {
37081
+ return `Shared team "${team.name}" has uncommitted changes; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or resolve them.`;
36974
37082
  }
36975
- if (parts[0] === "handoffs" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
36976
- return { kind: "handoff", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
37083
+ const ahead = await gitOutput(worktree, ["rev-list", "--count", "@{u}..HEAD"], false);
37084
+ if (ahead === void 0) {
37085
+ return `Shared team "${team.name}" upstream status is unknown; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to inspect and resolve it.`;
36977
37086
  }
36978
- if (parts[0] === "durable" && parts[1] === "projects" && parts[2] && parts[3]?.endsWith(".md")) {
36979
- return { kind: "durable", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
37087
+ if ((Number.parseInt(ahead, 10) || 0) > 0) {
37088
+ return `Shared team "${team.name}" has local commits ahead of upstream; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or reconcile them.`;
36980
37089
  }
36981
- if (parts[0] === "incidents" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
36982
- return { kind: "incident", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
37090
+ const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37091
+ const pullResult = await runCommand(git, ["-C", worktree, "rebase", "@{u}"], { allowFailure: true });
37092
+ if (pullResult.exitCode !== 0) {
37093
+ if (await exists((0, import_node_path3.join)(team.config.gitdir, "rebase-merge")) || await exists((0, import_node_path3.join)(team.config.gitdir, "rebase-apply"))) {
37094
+ throw new Error(
37095
+ `Automatic share sync hit git conflicts in ${worktree}. Resolve them in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then rerun recall/read.`
37096
+ );
37097
+ }
37098
+ throw new Error(
37099
+ `Automatic share sync failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
37100
+ );
37101
+ }
37102
+ const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37103
+ if (beforeRev && afterRev && beforeRev !== afterRev) {
37104
+ const changes = await listChangedFiles(worktree, beforeRev, afterRev);
37105
+ if (changes.length > 0) {
37106
+ const stillPending = state.pendingReindexes.get(team.name) ?? [];
37107
+ const combined = mergeChanges(stillPending, changes);
37108
+ await applyAndPersistChanges(config2, team.config, state, combined, { quiet: true });
37109
+ }
36983
37110
  }
36984
37111
  return void 0;
36985
37112
  }
36986
- function groupableRecord(record2) {
36987
- if (record2.metadata.status !== "active" || !isCompactableKind(record2.metadata.kind)) {
36988
- return void 0;
37113
+ async function publishShareGitChange(worktree, relativePath, commitMessage, options = {}) {
37114
+ const dryRun = options.dryRun === true;
37115
+ const push = options.push !== false;
37116
+ const verb = options.verb ?? "add";
37117
+ const git = await requiredExecutable("git");
37118
+ const messages = [];
37119
+ const paths = typeof relativePath === "string" ? [relativePath] : [...relativePath];
37120
+ const stageArgs = verb === "rm" ? ["-C", worktree, "rm", ...paths] : ["-C", worktree, "add", "--", ...paths];
37121
+ const stageResult = await runGitCommand(dryRun, git, stageArgs, `git ${verb} failed`);
37122
+ if (stageResult) {
37123
+ messages.push(`git ${verb}: ${stageResult.stdout.trim() || "ok"}`);
36989
37124
  }
36990
- const project = normalizeOptionalMetadata(record2.metadata.project) ?? parseProjectFromUri(record2.uri);
36991
- if (!project) {
36992
- return void 0;
37125
+ if (dryRun) {
37126
+ console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "commit", "-m", commitMessage])}`);
37127
+ } else {
37128
+ const commitResult = await runCommand(git, ["-C", worktree, "commit", "-m", commitMessage], { allowFailure: true });
37129
+ if (commitResult.exitCode !== 0) {
37130
+ const detail = commitResult.stdout.trim() || commitResult.stderr.trim();
37131
+ if (/nothing to commit|no changes added/i.test(detail)) {
37132
+ messages.push("git commit: nothing to commit (file already in tree)");
37133
+ } else {
37134
+ throw new Error(`git commit failed: ${detail || "unknown error"}`);
37135
+ }
37136
+ } else {
37137
+ messages.push(`git commit: ${commitResult.stdout.trim().split("\n").slice(0, 2).join(" ")}`);
37138
+ }
36993
37139
  }
36994
- const topic = topicForRecord(record2);
36995
- const groupKey = [record2.metadata.kind, project, topic ?? record2.uri].join("\0");
36996
- return { groupKey, project, record: record2, topic };
36997
- }
36998
- function topicForRecord(record2) {
36999
- return normalizeOptionalMetadata(record2.metadata.topic) ?? normalizeOptionalMetadata(branchFromBody(record2.body)) ?? topicFromUri(record2.uri);
37140
+ if (!push) {
37141
+ messages.push("git push skipped (push=false)");
37142
+ return messages;
37143
+ }
37144
+ const pushResult = await runGitCommand(
37145
+ dryRun,
37146
+ git,
37147
+ ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME],
37148
+ "git push failed"
37149
+ );
37150
+ if (pushResult) {
37151
+ messages.push(`git push: ${pushResult.stdout.trim() || pushResult.stderr.trim() || "ok"}`);
37152
+ }
37153
+ return messages;
37000
37154
  }
37001
- function branchFromBody(body) {
37002
- const branch = /^branch:\s*(.+)$/m.exec(body)?.[1]?.trim();
37003
- return branch?.split(/\s+/)[0]?.replace(/[.,;:]+$/g, "");
37155
+ async function runGitCommand(dryRun, git, args, failureLabel) {
37156
+ if (dryRun) {
37157
+ console.log(`Would run: ${formatShellCommand(git, args)}`);
37158
+ return void 0;
37159
+ }
37160
+ const result = await runCommand(git, args, { allowFailure: true });
37161
+ if (result.exitCode !== 0) {
37162
+ throw new Error(`${failureLabel}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
37163
+ }
37164
+ return result;
37004
37165
  }
37005
- function topicFromUri(uri) {
37006
- const name = (0, import_node_path3.basename)(uri).replace(/\.md$/, "");
37007
- return name.startsWith("threadnote-") ? void 0 : name;
37166
+ async function shareAgentArtifact(config2, sourcePath, options) {
37167
+ const team = await resolveTeam(config2, options.team);
37168
+ const resolvedSourcePath = expandPath(sourcePath);
37169
+ if (!await isRegularFileNoSymlink(resolvedSourcePath)) {
37170
+ throw new Error(`Agent artifact source is not a regular file: ${resolvedSourcePath}`);
37171
+ }
37172
+ const artifact = inferShareArtifact(resolvedSourcePath, options);
37173
+ if (artifact.kind === "skill") {
37174
+ const skillDir = (0, import_node_path3.dirname)(resolvedSourcePath);
37175
+ const members = await collectBundleMemberFiles(skillDir);
37176
+ if (members.length > 1) {
37177
+ return shareBundleArtifact(config2, team, artifact, skillDir, members, options);
37178
+ }
37179
+ }
37180
+ return shareSingleArtifact(config2, team, resolvedSourcePath, artifact, options);
37008
37181
  }
37009
- function parseProjectFromUri(uri) {
37010
- const parts = uri.split("/memories/")[1]?.split("/").filter(Boolean) ?? [];
37011
- if ((parts[0] === "handoffs" || parts[0] === "incidents") && parts[1] === "active") {
37012
- return parts[2];
37182
+ async function shareSingleArtifact(config2, team, resolvedSourcePath, artifact, options) {
37183
+ const dryRun = options.dryRun === true;
37184
+ const preview = options.preview === true;
37185
+ const rawContent = await (0, import_promises4.readFile)(resolvedSourcePath, "utf8");
37186
+ if (!rawContent.trim()) {
37187
+ throw new Error(`Refusing to share empty agent artifact: ${resolvedSourcePath}`);
37013
37188
  }
37014
- if (parts[0] === "durable" && parts[1] === "projects") {
37015
- return parts[2];
37189
+ const scrub = applyScrubber(rawContent, { redact: options.redact === true });
37190
+ const relativePath = sharedArtifactRelativePath(artifact);
37191
+ const targetPath = (0, import_node_path3.join)(team.config.worktree, ...relativePath.split("/"));
37192
+ const targetUri = workfileToVikingUri(config2, team.config, targetPath);
37193
+ const messages = [
37194
+ `${preview ? "Previewing" : dryRun ? "Would share" : "Sharing"} ${artifact.kind} ${artifact.agent}/${artifact.name}`,
37195
+ `Source: ${portablePath(resolvedSourcePath)}`,
37196
+ `Destination: ${targetUri}`
37197
+ ];
37198
+ if (preview) {
37199
+ if (scrub.blocker) {
37200
+ messages.push(`PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or pass --redact.`);
37201
+ return {
37202
+ artifact,
37203
+ gitMessages: [],
37204
+ messages,
37205
+ sourcePath: resolvedSourcePath,
37206
+ targetPath,
37207
+ targetUri
37208
+ };
37209
+ }
37210
+ for (const redaction of scrub.redactions) {
37211
+ messages.push(`PREVIEW redact: ${redaction.count}\xD7 ${redaction.name}`);
37212
+ }
37213
+ return {
37214
+ artifact,
37215
+ gitMessages: [],
37216
+ messages,
37217
+ previewContent: scrub.cleaned,
37218
+ sourcePath: resolvedSourcePath,
37219
+ targetPath,
37220
+ targetUri
37221
+ };
37016
37222
  }
37017
- return void 0;
37223
+ if (scrub.blocker) {
37224
+ throw new Error(
37225
+ `Refusing to share ${resolvedSourcePath}: possible ${scrub.blocker}. Strip the sensitive value or pass --redact for soft-leak patterns.`
37226
+ );
37227
+ }
37228
+ for (const redaction of scrub.redactions) {
37229
+ messages.push(`Redacted ${redaction.count}\xD7 ${redaction.name} before sharing.`);
37230
+ }
37231
+ const content = scrub.cleaned;
37232
+ const existingContent = await readFileIfExists(targetPath) ?? void 0;
37233
+ if (existingContent !== void 0 && existingContent !== content && options.force !== true) {
37234
+ throw new Error(
37235
+ `Shared artifact already exists with different content: ${portablePath(targetPath)}. Pass --force to replace it.`
37236
+ );
37237
+ }
37238
+ if (dryRun) {
37239
+ messages.push(`Would write shared artifact: ${portablePath(targetPath)}`);
37240
+ }
37241
+ const ov = await openVikingCliForMode(dryRun);
37242
+ const ovHasResource = !dryRun && await vikingResourceExists(ov, config2, targetUri);
37243
+ await ensureSharedDirectoryChain(config2, ov, targetUri, dryRun, { quiet: true });
37244
+ await writeMemoryFile(config2, ov, targetUri, content, ovHasResource ? "replace" : "create", dryRun, { quiet: true });
37245
+ const message = options.message ?? `share: publish ${relativePath}`;
37246
+ const gitMessages = await publishShareGitChange(team.config.worktree, relativePath, message, {
37247
+ dryRun,
37248
+ push: options.push
37249
+ });
37250
+ return { artifact, gitMessages, messages, sourcePath: resolvedSourcePath, targetPath, targetUri };
37018
37251
  }
37019
- function comparableMemoryBody(body) {
37020
- return stripHygieneSources(body).trim().replace(/\s+/g, " ");
37252
+ async function shareBundleArtifact(config2, team, artifact, skillDir, members, options) {
37253
+ const dryRun = options.dryRun === true;
37254
+ const preview = options.preview === true;
37255
+ const skillRootRelative = `${SHAREABLE_ARTIFACT_DIR}/skills/${artifact.agent}/${artifact.name}`;
37256
+ const skillRootTargetDir = (0, import_node_path3.join)(team.config.worktree, ...skillRootRelative.split("/"));
37257
+ const skillMdTargetPath = (0, import_node_path3.join)(skillRootTargetDir, "SKILL.md");
37258
+ const skillMdTargetUri = workfileToVikingUri(config2, team.config, skillMdTargetPath);
37259
+ const skillRootTargetUri = parentUri(skillMdTargetUri);
37260
+ const skillMdSourcePath = (0, import_node_path3.join)(skillDir, "SKILL.md");
37261
+ const prepared = await Promise.all(
37262
+ members.map((member) => prepareBundleMember(config2, team, member, skillRootTargetDir, options))
37263
+ );
37264
+ const skillMd = prepared.find((entry) => entry.relativePath === "SKILL.md");
37265
+ if (skillMd === void 0) {
37266
+ throw new Error(`Skill bundle ${artifact.agent}/${artifact.name} is missing SKILL.md.`);
37267
+ }
37268
+ if (!skillMd.binary && typeof skillMd.content === "string" && !skillMd.content.trim()) {
37269
+ throw new Error(`Refusing to share empty agent artifact: ${skillMdSourcePath}`);
37270
+ }
37271
+ const messages = [
37272
+ `${preview ? "Previewing" : dryRun ? "Would share" : "Sharing"} skill ${artifact.agent}/${artifact.name} bundle (${prepared.length} files)`,
37273
+ `Source: ${portablePath(skillDir)}`,
37274
+ `Destination: ${skillRootTargetUri}/`
37275
+ ];
37276
+ const blockers = prepared.filter((entry) => entry.blocker !== void 0);
37277
+ if (preview) {
37278
+ for (const entry of prepared) {
37279
+ const flags = entry.binary ? ["binary"] : [];
37280
+ for (const redaction of entry.redactions) {
37281
+ flags.push(`redact ${redaction.count}\xD7 ${redaction.name}`);
37282
+ }
37283
+ const note = entry.blocker !== void 0 ? ` BLOCKED: ${entry.blocker}` : "";
37284
+ messages.push(` ${entry.relativePath}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${note}`);
37285
+ }
37286
+ return {
37287
+ artifact,
37288
+ gitMessages: [],
37289
+ messages,
37290
+ previewContent: skillMd.binary ? void 0 : skillMd.content,
37291
+ sourcePath: skillMdSourcePath,
37292
+ targetPath: skillMdTargetPath,
37293
+ targetUri: skillMdTargetUri
37294
+ };
37295
+ }
37296
+ if (blockers.length > 0) {
37297
+ throw new Error(
37298
+ `Refusing to share skill ${artifact.agent}/${artifact.name}: ${blockers.map((entry) => `${entry.relativePath} (${entry.blocker})`).join("; ")}. Strip the value, pass --redact for local paths, or --allow-binary for binary files.`
37299
+ );
37300
+ }
37301
+ for (const entry of prepared) {
37302
+ for (const redaction of entry.redactions) {
37303
+ messages.push(`Redacted ${redaction.count}\xD7 ${redaction.name} in ${entry.relativePath} before sharing.`);
37304
+ }
37305
+ }
37306
+ for (const entry of prepared) {
37307
+ const existing = await readFileBytesIfExists(entry.targetPath);
37308
+ if (existing !== void 0 && sha256(existing) !== entry.sha256 && options.force !== true) {
37309
+ throw new Error(
37310
+ `Shared artifact already exists with different content: ${portablePath(entry.targetPath)}. Pass --force to replace it.`
37311
+ );
37312
+ }
37313
+ }
37314
+ if (dryRun) {
37315
+ messages.push(`Would write ${prepared.length} files under ${portablePath(skillRootTargetDir)}`);
37316
+ return {
37317
+ artifact,
37318
+ gitMessages: [],
37319
+ messages,
37320
+ sourcePath: skillMdSourcePath,
37321
+ targetPath: skillMdTargetPath,
37322
+ targetUri: skillMdTargetUri
37323
+ };
37324
+ }
37325
+ const ov = await openVikingCliForMode(dryRun);
37326
+ const markdownMembers = orderSkillMdFirst(prepared.filter((entry) => entry.relativePath.endsWith(".md")));
37327
+ const otherMembers = prepared.filter((entry) => !entry.relativePath.endsWith(".md"));
37328
+ for (const entry of markdownMembers) {
37329
+ const ovHasResource = await vikingResourceExists(ov, config2, entry.targetUri);
37330
+ await ensureSharedDirectoryChain(config2, ov, entry.targetUri, dryRun, { quiet: true });
37331
+ await writeMemoryFile(
37332
+ config2,
37333
+ ov,
37334
+ entry.targetUri,
37335
+ entry.content,
37336
+ ovHasResource ? "replace" : "create",
37337
+ dryRun,
37338
+ { quiet: true }
37339
+ );
37340
+ }
37341
+ await ensureDirectory(skillRootTargetDir, false);
37342
+ for (const entry of otherMembers) {
37343
+ await ensureDirectory((0, import_node_path3.dirname)(entry.targetPath), false);
37344
+ await (0, import_promises4.writeFile)(entry.targetPath, entry.content, entry.binary ? { mode: 384 } : { encoding: "utf8", mode: 384 });
37345
+ }
37346
+ await (0, import_promises4.writeFile)((0, import_node_path3.join)(skillRootTargetDir, BUNDLE_MANIFEST_FILE), buildBundleManifest(artifact, prepared), {
37347
+ encoding: "utf8",
37348
+ mode: 384
37349
+ });
37350
+ const stagedPaths = [
37351
+ ...prepared.map((entry) => `${skillRootRelative}/${entry.relativePath}`),
37352
+ `${skillRootRelative}/${BUNDLE_MANIFEST_FILE}`
37353
+ ];
37354
+ const message = options.message ?? `share: publish skill ${artifact.agent}/${artifact.name} (${prepared.length} files)`;
37355
+ const gitMessages = await publishShareGitChange(team.config.worktree, stagedPaths, message, {
37356
+ dryRun,
37357
+ push: options.push
37358
+ });
37359
+ return {
37360
+ artifact,
37361
+ gitMessages,
37362
+ messages,
37363
+ sourcePath: skillMdSourcePath,
37364
+ targetPath: skillMdTargetPath,
37365
+ targetUri: skillMdTargetUri
37366
+ };
37021
37367
  }
37022
- function stripHygieneSources(body) {
37023
- const index = body.indexOf(`
37024
- ${HYGIENE_SOURCES_HEADING}`);
37025
- if (index !== -1) {
37026
- return body.slice(0, index).trim();
37368
+ async function prepareBundleMember(config2, team, member, skillRootTargetDir, options) {
37369
+ const buffer = await (0, import_promises4.readFile)(member.absolutePath);
37370
+ const targetPath = (0, import_node_path3.join)(skillRootTargetDir, ...member.relativePath.split("/"));
37371
+ const targetUri = workfileToVikingUri(config2, team.config, targetPath);
37372
+ if (isProbablyBinary(buffer)) {
37373
+ const credential = detectBinaryCredential(buffer);
37374
+ const blocker = credential !== void 0 ? `possible ${credential} embedded in binary file` : options.allowBinary === true ? void 0 : "binary file (pass --allow-binary to include it unscanned)";
37375
+ return {
37376
+ binary: true,
37377
+ blocker,
37378
+ content: buffer,
37379
+ redactions: [],
37380
+ relativePath: member.relativePath,
37381
+ sha256: sha256(buffer),
37382
+ targetPath,
37383
+ targetUri
37384
+ };
37027
37385
  }
37028
- return body.startsWith(HYGIENE_SOURCES_HEADING) ? "" : body.trim();
37386
+ const scrub = applyScrubber(buffer.toString("utf8"), { redact: options.redact === true });
37387
+ return {
37388
+ binary: false,
37389
+ blocker: scrub.blocker,
37390
+ content: scrub.cleaned,
37391
+ redactions: scrub.redactions,
37392
+ relativePath: member.relativePath,
37393
+ sha256: sha256(scrub.cleaned),
37394
+ targetPath,
37395
+ targetUri
37396
+ };
37029
37397
  }
37030
- function preferredKeepRecord(records, topic) {
37031
- const stableRecords = records.filter((record2) => isStableRecord(record2, topic));
37032
- return sortedNewestFirst(stableRecords.length > 0 ? stableRecords : records)[0] ?? records[0];
37398
+ function orderSkillMdFirst(entries) {
37399
+ return [...entries].sort((a, b) => {
37400
+ if (a.relativePath === "SKILL.md") {
37401
+ return -1;
37402
+ }
37403
+ if (b.relativePath === "SKILL.md") {
37404
+ return 1;
37405
+ }
37406
+ return compareStrings(a.relativePath, b.relativePath);
37407
+ });
37033
37408
  }
37034
- function isStableRecord(record2, topic) {
37035
- const recordTopic = topic ?? topicForRecord(record2);
37036
- return recordTopic !== void 0 && (0, import_node_path3.basename)(record2.uri) === `${uriSegment(recordTopic)}.md`;
37409
+ function buildBundleManifest(artifact, prepared) {
37410
+ const manifest = {
37411
+ artifact,
37412
+ members: prepared.map((entry) => ({ binary: entry.binary, path: entry.relativePath, sha256: entry.sha256 })).sort((a, b) => compareStrings(a.path, b.path)),
37413
+ version: BUNDLE_MANIFEST_VERSION
37414
+ };
37415
+ return `${JSON.stringify(manifest, void 0, 2)}
37416
+ `;
37037
37417
  }
37038
- function sortedNewestFirst(records) {
37039
- return [...records].sort((left, right) => {
37040
- const timestampDiff = timestampMs(right) - timestampMs(left);
37041
- return timestampDiff === 0 ? left.uri.localeCompare(right.uri) : timestampDiff;
37042
- });
37418
+ async function collectBundleMemberFiles(skillDir) {
37419
+ const out = [];
37420
+ async function visit(dir) {
37421
+ const entries = await (0, import_promises4.readdir)(dir, { withFileTypes: true });
37422
+ for (const entry of entries) {
37423
+ if (entry.isSymbolicLink()) {
37424
+ continue;
37425
+ }
37426
+ const full = (0, import_node_path3.join)(dir, entry.name);
37427
+ if (entry.isDirectory()) {
37428
+ if (!BUNDLE_IGNORE_DIR_NAMES.includes(entry.name)) {
37429
+ await visit(full);
37430
+ }
37431
+ continue;
37432
+ }
37433
+ if (!entry.isFile() || isIgnoredBundleFile(entry.name)) {
37434
+ continue;
37435
+ }
37436
+ out.push({ absolutePath: full, relativePath: (0, import_node_path3.relative)(skillDir, full).split(import_node_path3.sep).join("/") });
37437
+ }
37438
+ }
37439
+ await visit(skillDir);
37440
+ return out.sort((a, b) => compareStrings(a.relativePath, b.relativePath));
37043
37441
  }
37044
- function timestampMs(record2) {
37045
- const parsed = Date.parse(record2.metadata.timestamp);
37046
- return Number.isFinite(parsed) ? parsed : 0;
37442
+ function isIgnoredBundleFile(name) {
37443
+ if (name === ".DS_Store" || name === BUNDLE_MANIFEST_FILE || name === BUNDLE_INSTALL_METADATA_FILE) {
37444
+ return true;
37445
+ }
37446
+ if (OV_SUMMARY_FILES.includes(name)) {
37447
+ return true;
37448
+ }
37449
+ return name.endsWith(".log") || name.endsWith(".threadnote-install.json");
37047
37450
  }
37048
- function isStaleLookingHandoff(record2, now) {
37049
- if (record2.metadata.kind !== "handoff") {
37050
- return false;
37451
+ function isProbablyBinary(buffer) {
37452
+ if (buffer.includes(0)) {
37453
+ return true;
37051
37454
  }
37052
- if (now.getTime() - timestampMs(record2) < STALE_HANDOFF_AGE_MS) {
37455
+ try {
37456
+ new import_node_util.TextDecoder("utf-8", { fatal: true }).decode(buffer);
37053
37457
  return false;
37458
+ } catch (_err) {
37459
+ return true;
37054
37460
  }
37055
- return /\b(?:PR|pull request)\s+(?:OPEN|open|is open)|awaiting review|waiting for review|next steps?:\s*address PR review/i.test(
37056
- record2.body
37057
- );
37058
37461
  }
37059
- function groupBy(values, keyForValue) {
37060
- const groups = /* @__PURE__ */ new Map();
37061
- for (const value of values) {
37062
- const key = keyForValue(value);
37063
- groups.set(key, [...groups.get(key) ?? [], value]);
37462
+ function detectBinaryCredential(buffer) {
37463
+ const latin1 = buffer.toString("latin1");
37464
+ for (const pattern of SCRUBBER_PATTERNS) {
37465
+ if (pattern.placeholder === void 0 && pattern.regex.test(latin1)) {
37466
+ return pattern.name;
37467
+ }
37064
37468
  }
37065
- return groups;
37066
- }
37067
- function compareGroupedRecordLists(left, right) {
37068
- return (left[0]?.groupKey ?? "").localeCompare(right[0]?.groupKey ?? "");
37469
+ return void 0;
37069
37470
  }
37070
- function dedupeByUri(items) {
37071
- const seen = /* @__PURE__ */ new Set();
37072
- const result = [];
37073
- for (const item of items) {
37074
- if (seen.has(item.uri)) {
37075
- continue;
37471
+ function detectBinaryLocalPath(buffer, rewriteRoots) {
37472
+ const latin1 = buffer.toString("latin1");
37473
+ for (const root of rewriteRoots) {
37474
+ if (root.length > 0 && latin1.includes(root)) {
37475
+ return "machine-local path";
37076
37476
  }
37077
- seen.add(item.uri);
37078
- result.push(item);
37079
37477
  }
37080
- return result;
37081
- }
37082
- function formatMemoryDocument(title, metadata, body) {
37083
- const header = [
37084
- title,
37085
- `kind: ${metadata.kind}`,
37086
- `status: ${metadata.status}`,
37087
- metadata.project ? `project: ${metadata.project}` : void 0,
37088
- metadata.topic ? `topic: ${metadata.topic}` : void 0,
37089
- `source_agent_client: ${metadata.sourceAgentClient}`,
37090
- `timestamp: ${metadata.timestamp}`,
37091
- metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
37092
- metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
37093
- ].filter((line) => line !== void 0);
37094
- return [...header, "", body.trim()].join("\n");
37095
- }
37096
- function headerValue(header, key) {
37097
- const prefix = `${key}:`;
37098
- return header.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim();
37099
- }
37100
- function parseOptionalMemoryKind(value) {
37101
- if (!value) {
37102
- return void 0;
37103
- }
37104
- if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
37105
- return value;
37478
+ for (const pattern of SCRUBBER_PATTERNS) {
37479
+ if (pattern.placeholder !== void 0 && pattern.regex.test(latin1)) {
37480
+ return pattern.name;
37481
+ }
37106
37482
  }
37107
37483
  return void 0;
37108
37484
  }
37109
- function parseOptionalMemoryStatus(value) {
37110
- if (!value) {
37485
+ async function readFileBytesIfExists(path) {
37486
+ try {
37487
+ return await (0, import_promises4.readFile)(path);
37488
+ } catch (_err) {
37111
37489
  return void 0;
37112
37490
  }
37113
- if (["active", "archived", "superseded"].includes(value)) {
37114
- return value;
37115
- }
37116
- return void 0;
37117
37491
  }
37118
- function normalizeOptionalMetadata(value) {
37119
- const trimmed = value?.trim();
37120
- return trimmed ? trimmed : void 0;
37492
+ function isBundleArtifact(artifact) {
37493
+ if (artifact.artifact.kind === "pack") {
37494
+ return true;
37495
+ }
37496
+ return artifact.members !== void 0 && artifact.members.length > 1;
37121
37497
  }
37122
- function isCompactableKind(kind) {
37123
- return kind === "durable" || kind === "handoff" || kind === "incident";
37498
+ function compareStrings(a, b) {
37499
+ return a < b ? -1 : a > b ? 1 : 0;
37124
37500
  }
37125
- function memoryKindPlural(kind) {
37126
- switch (kind) {
37127
- case "handoff":
37128
- return "handoffs";
37129
- case "incident":
37130
- return "incidents";
37131
- case "durable":
37132
- return "durable memories";
37133
- case "preference":
37134
- return "preferences";
37135
- case "smoke":
37136
- return "smoke memories";
37501
+ function parsePackManifest(raw, manifestPath) {
37502
+ const parsed = parseJsonConfigObject(raw);
37503
+ if (parsed === void 0) {
37504
+ throw new Error(`Invalid pack manifest (not a JSON object): ${manifestPath}`);
37137
37505
  }
37138
- }
37139
- function formatPlanSection(title, lines) {
37140
- if (lines.length === 0) {
37141
- return `${title}:
37142
- - none`;
37506
+ const name = parsed.name;
37507
+ if (typeof name !== "string" || name.trim().length === 0) {
37508
+ throw new Error(`Pack manifest must set a non-empty "name": ${manifestPath}`);
37143
37509
  }
37144
- return [`${title}:`, ...lines.map((line) => `- ${line}`)].join("\n");
37145
- }
37146
-
37147
- // src/share.ts
37148
- var import_promises4 = require("node:fs/promises");
37149
- var import_node_os2 = require("node:os");
37150
- var import_node_path4 = require("node:path");
37151
- var TEAMS_FILE_VERSION = 1;
37152
- var SHARED_SEGMENT = "shared";
37153
- var SHAREABLE_MEMORY_KIND_DIRS = ["durable"];
37154
- var SHAREABLE_ARTIFACT_DIR = "agent-artifacts";
37155
- var SHAREABLE_TOP_LEVEL_DIRS = [...SHAREABLE_MEMORY_KIND_DIRS, SHAREABLE_ARTIFACT_DIR];
37156
- var ARTIFACT_INSTALL_METADATA_VERSION = 1;
37157
- var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
37158
- var DEFAULT_GIT_REMOTE_NAME = "origin";
37159
- var SCRUBBER_PATTERNS = [
37160
- // Credentials: never redactable. Blocking is the only safe response —
37161
- // automated redaction risks false negatives that leave material in git.
37162
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
37163
- { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
37164
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
37165
- { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
37166
- { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
37167
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
37168
- // Matches bare JWTs (three base64url segments). May surface a JWE token in
37169
- // legitimate docs; if that becomes noisy we can switch to warn-only.
37170
- { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
37171
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
37172
- // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
37173
- // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
37174
- { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
37175
- // Soft leaks: block by default (so the agent sees them and decides), but
37176
- // allow opt-in redaction so curated memories with incidental matches can
37177
- // ship without a manual rewrite. Local home paths are the recurring
37178
- // real-world leak; the regexes greedily consume the whole path segment
37179
- // (including subdirectories) up to whitespace or common closing punctuation
37180
- // so redaction collapses an entire path to a single placeholder rather than
37181
- // leaving the subpath visible.
37182
- { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
37183
- { name: "linux home path", placeholder: "<local-path>", regex: /\b\/home\/[^\s)>"'`,]+/ }
37184
- ];
37185
- function applyScrubber(content, { redact }) {
37186
- let cleaned = content;
37187
- const redactions = [];
37188
- for (const pattern of SCRUBBER_PATTERNS) {
37189
- if (!pattern.regex.test(cleaned)) {
37190
- continue;
37191
- }
37192
- if (!pattern.placeholder || !redact) {
37193
- return { blocker: pattern.name, cleaned: content, redactions: [] };
37194
- }
37195
- const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
37196
- const globalRegex = new RegExp(pattern.regex.source, flags);
37197
- const matches = cleaned.match(globalRegex) ?? [];
37198
- cleaned = cleaned.replace(globalRegex, pattern.placeholder);
37199
- redactions.push({ count: matches.length, name: pattern.name });
37510
+ const agent = parsed.agent;
37511
+ if (agent !== "codex" && agent !== "claude") {
37512
+ throw new Error(`Pack manifest "agent" must be "codex" or "claude": ${manifestPath}`);
37200
37513
  }
37201
- return { cleaned, redactions };
37202
- }
37203
- var autoShareStates = /* @__PURE__ */ new Map();
37204
- function startShareBackgroundFetch(config2) {
37205
- const state = autoShareState(config2);
37206
- if (state.timer) {
37207
- return;
37514
+ const stringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
37515
+ const skills = stringArray(parsed.skills);
37516
+ if (skills.length === 0) {
37517
+ throw new Error(`Pack manifest must list at least one skill in "skills": ${manifestPath}`);
37208
37518
  }
37209
- void refreshShareUpdateState(config2, { force: true }).catch((err) => {
37210
- console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
37211
- });
37212
- state.timer = setInterval(() => {
37213
- void refreshShareUpdateState(config2, { force: false }).catch((err) => {
37214
- console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
37215
- });
37216
- }, AUTO_SHARE_FETCH_INTERVAL_MS);
37217
- state.timer.unref?.();
37519
+ const depsValue = parsed.deps ?? {};
37520
+ const pathRewrites = Array.isArray(parsed.pathRewrites) ? parsed.pathRewrites.map((entry) => typeof entry === "string" ? entry : entry?.from).filter((item) => typeof item === "string").map((rewrite) => rewrite.replace(/\/+$/, "")) : [];
37521
+ for (const rewrite of pathRewrites) {
37522
+ if (!(0, import_node_path3.isAbsolute)(rewrite) || rewrite.split("/").filter(Boolean).length < 2) {
37523
+ throw new Error(
37524
+ `Pack manifest pathRewrites entry must be an absolute repo-root path (got "${rewrite}"): ${manifestPath}`
37525
+ );
37526
+ }
37527
+ }
37528
+ return {
37529
+ agent,
37530
+ deps: {
37531
+ cli: stringArray(depsValue.cli),
37532
+ mcp: stringArray(depsValue.mcp),
37533
+ os: stringArray(depsValue.os),
37534
+ runtime: stringArray(depsValue.runtime)
37535
+ },
37536
+ description: typeof parsed.description === "string" ? parsed.description : void 0,
37537
+ include: stringArray(parsed.include),
37538
+ name,
37539
+ pathRewrites,
37540
+ skills
37541
+ };
37218
37542
  }
37219
- async function syncSharedReposBeforeAgentRead(config2) {
37220
- const state = autoShareState(config2);
37221
- return enqueueShareOperation(state, async () => {
37222
- await loadPendingReindexes(config2, state);
37223
- const warnings = await refreshShareUpdateStateLocked(config2, state, { force: false });
37224
- const syncTeams = /* @__PURE__ */ new Set([...state.behindTeams, ...state.pendingReindexes.keys()]);
37225
- if (syncTeams.size === 0) {
37226
- return { syncedTeams: [], warnings };
37543
+ async function collectPackMembers(manifestDir, manifest) {
37544
+ const members = /* @__PURE__ */ new Map();
37545
+ const addEntry = async (entry) => {
37546
+ const normalized = entry.split("/").filter(Boolean).join("/");
37547
+ if (normalized.split("/").includes("..")) {
37548
+ throw new Error(`Pack manifest entries must stay within the pack root (got "${entry}").`);
37227
37549
  }
37228
- const syncedTeams = [];
37229
- const remainingBehind = new Set(state.behindTeams);
37230
- for (const team of syncTeams) {
37231
- try {
37232
- const warning2 = await runShareSyncQuiet(config2, state, { team });
37233
- if (warning2) {
37234
- warnings.push(warning2);
37235
- } else {
37236
- remainingBehind.delete(team);
37237
- syncedTeams.push(team);
37238
- }
37239
- } catch (err) {
37240
- warnings.push(
37241
- `Auto-sync for shared team "${team}" failed: ${err instanceof Error ? err.message : String(err)}`
37242
- );
37550
+ const absolute = (0, import_node_path3.join)(manifestDir, ...normalized.split("/"));
37551
+ if (absolute !== manifestDir && !absolute.startsWith(manifestDir + import_node_path3.sep)) {
37552
+ throw new Error(`Pack manifest entry escapes the pack root: ${entry}`);
37553
+ }
37554
+ if (await isDirectory(absolute)) {
37555
+ for (const member of await collectBundleMemberFiles(absolute)) {
37556
+ const relativePath = `${normalized}/${member.relativePath}`;
37557
+ members.set(relativePath, { absolutePath: member.absolutePath, relativePath });
37243
37558
  }
37559
+ return;
37244
37560
  }
37245
- state.behindTeams = remainingBehind;
37246
- state.lastCheckedAt = Date.now();
37247
- return { syncedTeams, warnings };
37248
- });
37249
- }
37250
- function autoShareState(config2) {
37251
- const key = `${config2.agentContextHome}:${config2.account}:${config2.user}`;
37252
- let state = autoShareStates.get(key);
37253
- if (!state) {
37254
- state = { behindTeams: /* @__PURE__ */ new Set(), lastCheckedAt: 0, pendingReindexes: /* @__PURE__ */ new Map() };
37255
- autoShareStates.set(key, state);
37561
+ if (await isRegularFileNoSymlink(absolute)) {
37562
+ members.set(normalized, { absolutePath: absolute, relativePath: normalized });
37563
+ return;
37564
+ }
37565
+ throw new Error(`Pack manifest references a missing path: ${entry}`);
37566
+ };
37567
+ for (const skill of manifest.skills) {
37568
+ const skillRel = skill.replace(/\/SKILL\.md$/i, "");
37569
+ const skillDir = (0, import_node_path3.join)(manifestDir, ...skillRel.split("/"));
37570
+ if (!await isFile((0, import_node_path3.join)(skillDir, "SKILL.md"))) {
37571
+ throw new Error(`Pack skill "${skill}" must be a directory containing SKILL.md.`);
37572
+ }
37573
+ await addEntry(skillRel);
37256
37574
  }
37257
- return state;
37575
+ for (const include of manifest.include) {
37576
+ await addEntry(include);
37577
+ }
37578
+ return [...members.values()].sort((a, b) => compareStrings(a.relativePath, b.relativePath));
37258
37579
  }
37259
- function pendingReindexesPath(config2) {
37260
- return (0, import_node_path4.join)(config2.agentContextHome, "share", "auto-sync-pending-reindexes.json");
37580
+ function escapeRegExp(value) {
37581
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37261
37582
  }
37262
- async function loadPendingReindexes(config2, state) {
37263
- const raw = await readFileIfExists(pendingReindexesPath(config2));
37264
- if (!raw) {
37265
- state.pendingReindexes = /* @__PURE__ */ new Map();
37266
- return;
37267
- }
37268
- const parsed = parseJsonConfigObject(raw);
37269
- if (!parsed || typeof parsed.teams !== "object" || parsed.teams === null || Array.isArray(parsed.teams)) {
37270
- state.pendingReindexes = /* @__PURE__ */ new Map();
37271
- return;
37272
- }
37273
- const pending = /* @__PURE__ */ new Map();
37274
- for (const [team, value] of Object.entries(parsed.teams)) {
37275
- if (!Array.isArray(value)) {
37276
- continue;
37277
- }
37278
- const changes = [];
37279
- for (const item of value) {
37280
- if (typeof item !== "object" || item === null || Array.isArray(item)) {
37281
- continue;
37282
- }
37283
- const entry = item;
37284
- if (typeof entry.path === "string" && typeof entry.relativePath === "string" && (entry.status === "added" || entry.status === "removed" || entry.status === "modified")) {
37285
- changes.push({ path: entry.path, relativePath: entry.relativePath, status: entry.status });
37286
- }
37583
+ function tokenizePackPaths(text, rewriteRoots) {
37584
+ let out = text;
37585
+ for (const root of rewriteRoots) {
37586
+ if (root.length > 0) {
37587
+ out = out.replace(
37588
+ new RegExp(`(?<![A-Za-z0-9/._~-])${escapeRegExp(root)}(?=[/\\\\]|["'\`\\s),>:\\]};=]|$)`, "g"),
37589
+ PACK_ROOT_TOKEN
37590
+ );
37287
37591
  }
37288
- if (changes.length > 0) {
37289
- pending.set(team, changes);
37592
+ }
37593
+ return out;
37594
+ }
37595
+ function residualRewriteRoot(content, rewriteRoots) {
37596
+ return rewriteRoots.find((root) => root.length > 0 && content.includes(root));
37597
+ }
37598
+ var PORTABLE_PATH_PREFIXES = [
37599
+ "/usr/",
37600
+ "/bin/",
37601
+ "/sbin/",
37602
+ "/lib/",
37603
+ "/lib64/",
37604
+ "/etc/",
37605
+ "/opt/homebrew/",
37606
+ "/tmp/",
37607
+ "/var/",
37608
+ "/private/var/",
37609
+ "/dev/",
37610
+ "/proc/",
37611
+ "/run/",
37612
+ "/sys/",
37613
+ "/Library/",
37614
+ "/System/",
37615
+ "/Applications/"
37616
+ ];
37617
+ function unportableAbsolutePaths(content) {
37618
+ const scan = content.split(`${PACK_ROOT_TOKEN}/`).join("").split(PACK_ROOT_TOKEN).join("");
37619
+ const found = /* @__PURE__ */ new Set();
37620
+ for (const path of scan.match(/(?<![A-Za-z0-9._~$-])\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+/g) ?? []) {
37621
+ if (!PORTABLE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) {
37622
+ found.add(path);
37290
37623
  }
37291
37624
  }
37292
- state.pendingReindexes = pending;
37625
+ for (const path of scan.match(/[A-Za-z]:\\[^\s"']+/g) ?? []) {
37626
+ found.add(path);
37627
+ }
37628
+ return [...found].sort((a, b) => compareStrings(a, b));
37293
37629
  }
37294
- async function writePendingReindexes(config2, state) {
37295
- const path = pendingReindexesPath(config2);
37296
- if (state.pendingReindexes.size === 0) {
37297
- await (0, import_promises4.rm)(path, { force: true });
37298
- return;
37630
+ async function preparePackMember(config2, team, member, filesTargetDir, rewriteRoots, options) {
37631
+ const buffer = await (0, import_promises4.readFile)(member.absolutePath);
37632
+ const targetPath = (0, import_node_path3.join)(filesTargetDir, ...member.relativePath.split("/"));
37633
+ const targetUri = workfileToVikingUri(config2, team.config, targetPath);
37634
+ if (isProbablyBinary(buffer)) {
37635
+ const credential = detectBinaryCredential(buffer);
37636
+ const localPath = credential === void 0 ? detectBinaryLocalPath(buffer, rewriteRoots) : void 0;
37637
+ let blocker2;
37638
+ if (credential !== void 0) {
37639
+ blocker2 = `possible ${credential} embedded in binary file`;
37640
+ } else if (options.allowBinary !== true) {
37641
+ blocker2 = "binary file (pass --allow-binary to include it unscanned)";
37642
+ } else if (localPath !== void 0) {
37643
+ blocker2 = `possible ${localPath} embedded in binary file (cannot be rewritten)`;
37644
+ }
37645
+ return {
37646
+ binary: true,
37647
+ blocker: blocker2,
37648
+ content: buffer,
37649
+ redactions: [],
37650
+ relativePath: member.relativePath,
37651
+ sha256: sha256(buffer),
37652
+ targetPath,
37653
+ targetUri
37654
+ };
37299
37655
  }
37300
- const contents = {
37301
- teams: Object.fromEntries(state.pendingReindexes),
37302
- version: 1
37656
+ const text = buffer.toString("utf8");
37657
+ if (text.includes(PACK_ROOT_TOKEN)) {
37658
+ return {
37659
+ binary: false,
37660
+ blocker: `contains the reserved ${PACK_ROOT_TOKEN} token`,
37661
+ content: text,
37662
+ redactions: [],
37663
+ relativePath: member.relativePath,
37664
+ sha256: sha256(text),
37665
+ targetPath,
37666
+ targetUri
37667
+ };
37668
+ }
37669
+ const tokenized = tokenizePackPaths(text, rewriteRoots);
37670
+ const isMarkdown = member.relativePath.toLowerCase().endsWith(".md");
37671
+ const scrub = applyScrubber(tokenized, { redact: isMarkdown && options.redact === true });
37672
+ const residual = residualRewriteRoot(scrub.cleaned, rewriteRoots);
37673
+ const blocker = scrub.blocker ?? (residual !== void 0 ? `machine-local path "${residual}" not rewritten` : void 0);
37674
+ return {
37675
+ binary: false,
37676
+ blocker,
37677
+ content: scrub.cleaned,
37678
+ redactions: scrub.redactions,
37679
+ relativePath: member.relativePath,
37680
+ sha256: sha256(scrub.cleaned),
37681
+ targetPath,
37682
+ targetUri
37303
37683
  };
37304
- await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
37305
- const tempPath = `${path}.${process.pid}.tmp`;
37306
- await (0, import_promises4.writeFile)(tempPath, `${JSON.stringify(contents, void 0, 2)}
37307
- `, { encoding: "utf8", mode: 384 });
37308
- await (0, import_promises4.rename)(tempPath, path);
37309
37684
  }
37310
- async function refreshShareUpdateState(config2, options) {
37311
- const state = autoShareState(config2);
37312
- const warnings = await enqueueShareOperation(
37313
- state,
37314
- async () => refreshShareUpdateStateLocked(config2, state, options)
37315
- );
37316
- for (const warning2 of warnings) {
37317
- console.error(warning2);
37685
+ function buildPackIndex(artifact, manifest, skillNames, memberCount) {
37686
+ const lines = [
37687
+ "---",
37688
+ `name: ${artifact.name}`,
37689
+ `agent: ${artifact.agent}`,
37690
+ "kind: pack",
37691
+ `skills: [${skillNames.join(", ")}]`,
37692
+ "---",
37693
+ "",
37694
+ `# ${artifact.name} (skill pack)`,
37695
+ "",
37696
+ manifest.description ?? `A Threadnote skill pack bundling ${skillNames.length} skill(s) and their shared support files (${memberCount} files total).`,
37697
+ "",
37698
+ "## Skills",
37699
+ ...skillNames.map((skill) => `- ${skill}`),
37700
+ "",
37701
+ "## Requirements",
37702
+ "Threadnote installs files only. Ensure these exist on the target machine before running:"
37703
+ ];
37704
+ if (manifest.deps.runtime.length > 0) {
37705
+ lines.push(`- runtime: ${manifest.deps.runtime.join(", ")}`);
37318
37706
  }
37319
- }
37320
- async function refreshShareUpdateStateLocked(config2, state, options) {
37321
- const now = Date.now();
37322
- if (!options.force && state.lastCheckedAt > 0 && now - state.lastCheckedAt < AUTO_SHARE_FETCH_INTERVAL_MS) {
37323
- return [];
37324
- }
37325
- try {
37326
- const statuses = await fetchShareUpdateStatuses(config2);
37327
- const nextBehindTeams = new Set(state.behindTeams);
37328
- for (const status of statuses) {
37329
- if (status.warning) {
37330
- continue;
37331
- }
37332
- if (status.behind > 0) {
37333
- nextBehindTeams.add(status.team);
37334
- } else {
37335
- nextBehindTeams.delete(status.team);
37336
- }
37337
- }
37338
- state.behindTeams = nextBehindTeams;
37339
- return statuses.flatMap((status) => status.warning ? [status.warning] : []);
37340
- } finally {
37341
- state.lastCheckedAt = Date.now();
37342
- }
37343
- }
37344
- function enqueueShareOperation(state, action) {
37345
- const previous = state.operationPromise ?? Promise.resolve();
37346
- const current = previous.catch(() => void 0).then(action);
37347
- state.operationPromise = current.catch(() => void 0);
37348
- return current;
37349
- }
37350
- async function fetchShareUpdateStatuses(config2) {
37351
- const teamsFile = await readTeamsFile(config2);
37352
- const teams = Object.entries(teamsFile.teams);
37353
- if (teams.length === 0) {
37354
- return [];
37355
- }
37356
- const git = await requiredExecutable("git");
37357
- const statuses = [];
37358
- for (const [name, team] of teams) {
37359
- const fetchResult = await runCommand(git, ["-C", team.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
37360
- allowFailure: true
37361
- });
37362
- if (fetchResult.exitCode !== 0) {
37363
- statuses.push({
37364
- behind: 0,
37365
- team: name,
37366
- warning: `Auto-sync check for shared team "${name}" failed: ${fetchResult.stderr.trim() || fetchResult.stdout.trim() || "unknown git fetch error"}`
37367
- });
37368
- continue;
37369
- }
37370
- const behind = await gitOutput(team.worktree, ["rev-list", "--count", "HEAD..@{u}"], false);
37371
- if (behind === void 0) {
37372
- statuses.push({
37373
- behind: 0,
37374
- team: name,
37375
- warning: `Auto-sync check for shared team "${name}" failed: could not read upstream behind count.`
37376
- });
37377
- continue;
37378
- }
37379
- statuses.push({ behind: Number.parseInt(behind, 10) || 0, team: name });
37380
- }
37381
- return statuses;
37382
- }
37383
- async function runShareSyncQuiet(config2, state, options) {
37384
- const team = await resolveTeam(config2, options.team);
37385
- const git = await requiredExecutable("git");
37386
- const worktree = team.config.worktree;
37387
- const pendingChanges = state.pendingReindexes.get(team.name);
37388
- if (pendingChanges && pendingChanges.length > 0) {
37389
- await applyAndPersistChanges(config2, team.config, state, pendingChanges, { quiet: true });
37390
- }
37391
- if (await hasUncommittedChanges(worktree)) {
37392
- return `Shared team "${team.name}" has uncommitted changes; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or resolve them.`;
37707
+ if (manifest.deps.cli.length > 0) {
37708
+ lines.push(`- CLI: ${manifest.deps.cli.join(", ")}`);
37393
37709
  }
37394
- const ahead = await gitOutput(worktree, ["rev-list", "--count", "@{u}..HEAD"], false);
37395
- if (ahead === void 0) {
37396
- return `Shared team "${team.name}" upstream status is unknown; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to inspect and resolve it.`;
37397
- }
37398
- if ((Number.parseInt(ahead, 10) || 0) > 0) {
37399
- return `Shared team "${team.name}" has local commits ahead of upstream; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or reconcile them.`;
37400
- }
37401
- const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37402
- const pullResult = await runCommand(git, ["-C", worktree, "rebase", "@{u}"], { allowFailure: true });
37403
- if (pullResult.exitCode !== 0) {
37404
- if (await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-merge")) || await exists((0, import_node_path4.join)(team.config.gitdir, "rebase-apply"))) {
37405
- throw new Error(
37406
- `Automatic share sync hit git conflicts in ${worktree}. Resolve them in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then rerun recall/read.`
37407
- );
37408
- }
37409
- throw new Error(
37410
- `Automatic share sync failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
37411
- );
37710
+ if (manifest.deps.os.length > 0) {
37711
+ lines.push(`- OS: ${manifest.deps.os.join(", ")}`);
37412
37712
  }
37413
- const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37414
- if (beforeRev && afterRev && beforeRev !== afterRev) {
37415
- const changes = await listChangedFiles(worktree, beforeRev, afterRev);
37416
- if (changes.length > 0) {
37417
- const stillPending = state.pendingReindexes.get(team.name) ?? [];
37418
- const combined = mergeChanges(stillPending, changes);
37419
- await applyAndPersistChanges(config2, team.config, state, combined, { quiet: true });
37420
- }
37713
+ if (manifest.deps.mcp.length > 0) {
37714
+ lines.push(`- MCP (configure separately): ${manifest.deps.mcp.join(", ")}`);
37421
37715
  }
37422
- return void 0;
37716
+ lines.push("", `Install: threadnote share install-artifacts --kind pack --name ${artifact.name} --apply`, "");
37717
+ return lines.join("\n");
37423
37718
  }
37424
- async function publishShareGitChange(worktree, relativePath, commitMessage, options = {}) {
37425
- const dryRun = options.dryRun === true;
37426
- const push = options.push !== false;
37427
- const verb = options.verb ?? "add";
37428
- const git = await requiredExecutable("git");
37429
- const messages = [];
37430
- const stageArgs = verb === "rm" ? ["-C", worktree, "rm", relativePath] : ["-C", worktree, "add", "--", relativePath];
37431
- const stageResult = await runGitCommand(dryRun, git, stageArgs, `git ${verb} failed`);
37432
- if (stageResult) {
37433
- messages.push(`git ${verb}: ${stageResult.stdout.trim() || "ok"}`);
37434
- }
37435
- if (dryRun) {
37436
- console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "commit", "-m", commitMessage])}`);
37437
- } else {
37438
- const commitResult = await runCommand(git, ["-C", worktree, "commit", "-m", commitMessage], { allowFailure: true });
37439
- if (commitResult.exitCode !== 0) {
37440
- const detail = commitResult.stdout.trim() || commitResult.stderr.trim();
37441
- if (/nothing to commit|no changes added/i.test(detail)) {
37442
- messages.push("git commit: nothing to commit (file already in tree)");
37443
- } else {
37444
- throw new Error(`git commit failed: ${detail || "unknown error"}`);
37445
- }
37446
- } else {
37447
- messages.push(`git commit: ${commitResult.stdout.trim().split("\n").slice(0, 2).join(" ")}`);
37448
- }
37449
- }
37450
- if (!push) {
37451
- messages.push("git push skipped (push=false)");
37452
- return messages;
37453
- }
37454
- const pushResult = await runGitCommand(
37455
- dryRun,
37456
- git,
37457
- ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME],
37458
- "git push failed"
37459
- );
37460
- if (pushResult) {
37461
- messages.push(`git push: ${pushResult.stdout.trim() || pushResult.stderr.trim() || "ok"}`);
37462
- }
37463
- return messages;
37719
+ function buildPackManifestJson(artifact, manifest, prepared) {
37720
+ const data = {
37721
+ artifact,
37722
+ deps: manifest.deps,
37723
+ members: prepared.map((entry) => ({ binary: entry.binary, path: entry.relativePath, sha256: entry.sha256 })).sort((a, b) => compareStrings(a.path, b.path)),
37724
+ version: BUNDLE_MANIFEST_VERSION
37725
+ };
37726
+ return `${JSON.stringify(data, void 0, 2)}
37727
+ `;
37464
37728
  }
37465
- async function runGitCommand(dryRun, git, args, failureLabel) {
37466
- if (dryRun) {
37467
- console.log(`Would run: ${formatShellCommand(git, args)}`);
37468
- return void 0;
37469
- }
37470
- const result = await runCommand(git, args, { allowFailure: true });
37471
- if (result.exitCode !== 0) {
37472
- throw new Error(`${failureLabel}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
37473
- }
37474
- return result;
37729
+ function packSkillName(skillEntry) {
37730
+ const trimmed = skillEntry.replace(/\/SKILL\.md$/i, "");
37731
+ return (0, import_node_path3.basename)(trimmed);
37475
37732
  }
37476
- async function shareAgentArtifact(config2, sourcePath, options) {
37733
+ async function shareBundlePack(config2, manifestPath, options) {
37477
37734
  const team = await resolveTeam(config2, options.team);
37478
37735
  const dryRun = options.dryRun === true;
37479
37736
  const preview = options.preview === true;
37480
- const resolvedSourcePath = expandPath(sourcePath);
37481
- if (!await isRegularFileNoSymlink(resolvedSourcePath)) {
37482
- throw new Error(`Agent artifact source is not a regular file: ${resolvedSourcePath}`);
37483
- }
37484
- const artifact = inferShareArtifact(resolvedSourcePath, options);
37485
- const rawContent = await (0, import_promises4.readFile)(resolvedSourcePath, "utf8");
37486
- if (!rawContent.trim()) {
37487
- throw new Error(`Refusing to share empty agent artifact: ${resolvedSourcePath}`);
37488
- }
37489
- const scrub = applyScrubber(rawContent, { redact: options.redact === true });
37490
- const relativePath = sharedArtifactRelativePath(artifact);
37491
- const targetPath = (0, import_node_path4.join)(team.config.worktree, ...relativePath.split("/"));
37492
- const targetUri = workfileToVikingUri(config2, team.config, targetPath);
37737
+ const resolvedManifest = expandPath(manifestPath);
37738
+ if (!await isRegularFileNoSymlink(resolvedManifest)) {
37739
+ throw new Error(`Pack manifest is not a regular file: ${resolvedManifest}`);
37740
+ }
37741
+ const manifest = parsePackManifest(await (0, import_promises4.readFile)(resolvedManifest, "utf8"), resolvedManifest);
37742
+ const manifestDir = (0, import_node_path3.dirname)(resolvedManifest);
37743
+ const artifact = { agent: manifest.agent, kind: "pack", name: uriSegment(manifest.name) };
37744
+ const skillNames = manifest.skills.map(packSkillName);
37745
+ const members = await collectPackMembers(manifestDir, manifest);
37746
+ const autoRoots = manifestDir.split("/").filter(Boolean).length >= 2 ? [manifestDir] : [];
37747
+ const rewriteRoots = [.../* @__PURE__ */ new Set([...autoRoots, ...manifest.pathRewrites])].sort((a, b) => b.length - a.length);
37748
+ const packRootRelative = `${SHAREABLE_ARTIFACT_DIR}/packs/${artifact.agent}/${artifact.name}`;
37749
+ const filesRelative = `${packRootRelative}/${PACK_FILES_DIR}`;
37750
+ const indexRelative = `${packRootRelative}/${artifact.name}${PACK_INDEX_SUFFIX}`;
37751
+ const manifestRelative = `${packRootRelative}/${artifact.name}${PACK_MANIFEST_SUFFIX}`;
37752
+ const filesTargetDir = (0, import_node_path3.join)(team.config.worktree, ...filesRelative.split("/"));
37753
+ const packRootTargetDir = (0, import_node_path3.join)(team.config.worktree, ...packRootRelative.split("/"));
37754
+ const indexTargetPath = (0, import_node_path3.join)(team.config.worktree, ...indexRelative.split("/"));
37755
+ const indexTargetUri = workfileToVikingUri(config2, team.config, indexTargetPath);
37756
+ const prepared = await Promise.all(
37757
+ members.map((member) => preparePackMember(config2, team, member, filesTargetDir, rewriteRoots, options))
37758
+ );
37759
+ const indexContent = tokenizePackPaths(buildPackIndex(artifact, manifest, skillNames, prepared.length), rewriteRoots);
37760
+ const indexScrub = applyScrubber(indexContent, { redact: options.redact === true });
37493
37761
  const messages = [
37494
- `${preview ? "Previewing" : dryRun ? "Would share" : "Sharing"} ${artifact.kind} ${artifact.agent}/${artifact.name}`,
37495
- `Source: ${portablePath(resolvedSourcePath)}`,
37496
- `Destination: ${targetUri}`
37762
+ `${preview ? "Previewing" : dryRun ? "Would share" : "Sharing"} pack ${artifact.agent}/${artifact.name} (${prepared.length} files, ${skillNames.length} skills)`,
37763
+ `Source: ${portablePath(manifestDir)}`,
37764
+ `Destination: ${indexTargetUri}`
37497
37765
  ];
37766
+ const blockers = prepared.filter((entry) => entry.blocker !== void 0);
37498
37767
  if (preview) {
37499
- if (scrub.blocker) {
37500
- messages.push(`PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or pass --redact.`);
37501
- return {
37502
- artifact,
37503
- gitMessages: [],
37504
- messages,
37505
- sourcePath: resolvedSourcePath,
37506
- targetPath,
37507
- targetUri
37508
- };
37509
- }
37510
- for (const redaction of scrub.redactions) {
37511
- messages.push(`PREVIEW redact: ${redaction.count}\xD7 ${redaction.name}`);
37768
+ for (const entry of prepared) {
37769
+ const flags = entry.binary ? ["binary"] : [];
37770
+ for (const redaction of entry.redactions) {
37771
+ flags.push(`redact ${redaction.count}\xD7 ${redaction.name}`);
37772
+ }
37773
+ const note = entry.blocker !== void 0 ? ` BLOCKED: ${entry.blocker}` : "";
37774
+ messages.push(` ${entry.relativePath}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${note}`);
37512
37775
  }
37513
37776
  return {
37514
37777
  artifact,
37515
37778
  gitMessages: [],
37516
37779
  messages,
37517
- previewContent: scrub.cleaned,
37518
- sourcePath: resolvedSourcePath,
37519
- targetPath,
37520
- targetUri
37780
+ previewContent: indexScrub.cleaned,
37781
+ sourcePath: resolvedManifest,
37782
+ targetPath: indexTargetPath,
37783
+ targetUri: indexTargetUri
37521
37784
  };
37522
37785
  }
37523
- if (scrub.blocker) {
37786
+ const indexResidual = residualRewriteRoot(indexScrub.cleaned, rewriteRoots);
37787
+ if (indexScrub.blocker !== void 0 || indexResidual !== void 0) {
37524
37788
  throw new Error(
37525
- `Refusing to share ${resolvedSourcePath}: possible ${scrub.blocker}. Strip the sensitive value or pass --redact for soft-leak patterns.`
37789
+ `Refusing to share pack ${artifact.agent}/${artifact.name}: index ${indexScrub.blocker ?? `machine-local path "${indexResidual}" not rewritten`}.`
37526
37790
  );
37527
37791
  }
37528
- for (const redaction of scrub.redactions) {
37529
- messages.push(`Redacted ${redaction.count}\xD7 ${redaction.name} before sharing.`);
37792
+ if (blockers.length > 0) {
37793
+ throw new Error(
37794
+ `Refusing to share pack ${artifact.agent}/${artifact.name}: ${blockers.map((entry) => `${entry.relativePath} (${entry.blocker})`).join("; ")}. Strip the value, pass --redact for local paths, or --allow-binary for binary files.`
37795
+ );
37530
37796
  }
37531
- const content = scrub.cleaned;
37532
- const existingContent = await readFileIfExists(targetPath) ?? void 0;
37533
- if (existingContent !== void 0 && existingContent !== content && options.force !== true) {
37797
+ const packJson = applyScrubber(tokenizePackPaths(buildPackManifestJson(artifact, manifest, prepared), rewriteRoots), {
37798
+ redact: options.redact === true
37799
+ });
37800
+ const packJsonResidual = residualRewriteRoot(packJson.cleaned, rewriteRoots);
37801
+ if (packJson.blocker !== void 0 || packJsonResidual !== void 0) {
37534
37802
  throw new Error(
37535
- `Shared artifact already exists with different content: ${portablePath(targetPath)}. Pass --force to replace it.`
37803
+ `Refusing to share pack ${artifact.agent}/${artifact.name}: manifest ${packJson.blocker ?? `machine-local path "${packJsonResidual}" not rewritten`}.`
37804
+ );
37805
+ }
37806
+ for (const entry of prepared) {
37807
+ for (const redaction of entry.redactions) {
37808
+ messages.push(`Redacted ${redaction.count}\xD7 ${redaction.name} in ${entry.relativePath} before sharing.`);
37809
+ }
37810
+ }
37811
+ const unportable = /* @__PURE__ */ new Set();
37812
+ for (const entry of prepared) {
37813
+ if (!entry.binary && typeof entry.content === "string") {
37814
+ for (const path of unportableAbsolutePaths(entry.content)) {
37815
+ unportable.add(`${entry.relativePath}: ${path}`);
37816
+ }
37817
+ }
37818
+ }
37819
+ for (const path of unportableAbsolutePaths(indexScrub.cleaned)) {
37820
+ unportable.add(`${artifact.name}${PACK_INDEX_SUFFIX}: ${path}`);
37821
+ }
37822
+ for (const path of unportableAbsolutePaths(packJson.cleaned)) {
37823
+ unportable.add(`${artifact.name}${PACK_MANIFEST_SUFFIX}: ${path}`);
37824
+ }
37825
+ if (unportable.size > 0) {
37826
+ messages.push(
37827
+ `Warning: possible machine-local absolute path(s) that will not resolve on a teammate's machine (declare in pathRewrites or strip if not portable): ${[...unportable].join("; ")}`
37536
37828
  );
37537
37829
  }
37830
+ for (const entry of prepared) {
37831
+ const existing = await readFileBytesIfExists(entry.targetPath);
37832
+ if (existing !== void 0 && sha256(existing) !== entry.sha256 && options.force !== true) {
37833
+ throw new Error(
37834
+ `Shared pack file already exists with different content: ${portablePath(entry.targetPath)}. Pass --force to replace it.`
37835
+ );
37836
+ }
37837
+ }
37538
37838
  if (dryRun) {
37539
- messages.push(`Would write shared artifact: ${portablePath(targetPath)}`);
37839
+ messages.push(`Would write ${prepared.length} files under ${portablePath(packRootTargetDir)}`);
37840
+ return {
37841
+ artifact,
37842
+ gitMessages: [],
37843
+ messages,
37844
+ sourcePath: resolvedManifest,
37845
+ targetPath: indexTargetPath,
37846
+ targetUri: indexTargetUri
37847
+ };
37540
37848
  }
37541
37849
  const ov = await openVikingCliForMode(dryRun);
37542
- const ovHasResource = !dryRun && await vikingResourceExists(ov, config2, targetUri);
37543
- await ensureSharedDirectoryChain(config2, ov, targetUri, dryRun, { quiet: true });
37544
- await writeMemoryFile(config2, ov, targetUri, content, ovHasResource ? "replace" : "create", dryRun, { quiet: true });
37545
- const message = options.message ?? `share: publish ${relativePath}`;
37546
- const gitMessages = await publishShareGitChange(team.config.worktree, relativePath, message, {
37850
+ const rollbacks = [];
37851
+ const manifestTargetPath = (0, import_node_path3.join)(team.config.worktree, ...manifestRelative.split("/"));
37852
+ try {
37853
+ const writeMarkdownMember = async (uri, content, worktreePath) => {
37854
+ const priorBytes = await readFileBytesIfExists(worktreePath);
37855
+ const hadResource = await vikingResourceExists(ov, config2, uri);
37856
+ await ensureSharedDirectoryChain(config2, ov, uri, dryRun, { quiet: true });
37857
+ await writeMemoryFile(config2, ov, uri, content, hadResource ? "replace" : "create", dryRun, { quiet: true });
37858
+ rollbacks.push(async () => {
37859
+ if (priorBytes !== void 0) {
37860
+ await writeMemoryFile(config2, ov, uri, priorBytes.toString("utf8"), "replace", false, { quiet: true });
37861
+ } else if (await vikingResourceExists(ov, config2, uri)) {
37862
+ await removeMemoryUri(config2, ov, uri, false, { quiet: true });
37863
+ }
37864
+ });
37865
+ };
37866
+ await writeMarkdownMember(indexTargetUri, indexScrub.cleaned, indexTargetPath);
37867
+ for (const entry of prepared.filter((member) => member.relativePath.endsWith(".md"))) {
37868
+ await writeMarkdownMember(entry.targetUri, entry.content, entry.targetPath);
37869
+ }
37870
+ await ensureDirectory(filesTargetDir, false);
37871
+ for (const entry of prepared.filter((member) => !member.relativePath.endsWith(".md"))) {
37872
+ const priorBytes = await readFileBytesIfExists(entry.targetPath);
37873
+ await ensureDirectory((0, import_node_path3.dirname)(entry.targetPath), false);
37874
+ await (0, import_promises4.writeFile)(entry.targetPath, entry.content, entry.binary ? { mode: 384 } : { encoding: "utf8", mode: 384 });
37875
+ rollbacks.push(async () => {
37876
+ if (priorBytes !== void 0) {
37877
+ await (0, import_promises4.writeFile)(entry.targetPath, priorBytes, { mode: 384 });
37878
+ } else {
37879
+ await (0, import_promises4.rm)(entry.targetPath, { force: true });
37880
+ }
37881
+ });
37882
+ }
37883
+ await ensureDirectory(packRootTargetDir, false);
37884
+ const priorManifest = await readFileBytesIfExists(manifestTargetPath);
37885
+ await (0, import_promises4.writeFile)(manifestTargetPath, packJson.cleaned, { encoding: "utf8", mode: 384 });
37886
+ rollbacks.push(async () => {
37887
+ if (priorManifest !== void 0) {
37888
+ await (0, import_promises4.writeFile)(manifestTargetPath, priorManifest, { mode: 384 });
37889
+ } else {
37890
+ await (0, import_promises4.rm)(manifestTargetPath, { force: true });
37891
+ }
37892
+ });
37893
+ const currentFiles = new Set(prepared.map((entry) => `${filesRelative}/${entry.relativePath}`));
37894
+ const git = await requiredExecutable("git");
37895
+ const tracked = await runCommand(git, ["-C", team.config.worktree, "ls-files", "--", filesRelative], {
37896
+ allowFailure: true
37897
+ });
37898
+ const stalePaths = tracked.exitCode === 0 ? tracked.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !currentFiles.has(line)) : [];
37899
+ for (const stale of stalePaths) {
37900
+ await runCommand(git, ["-C", team.config.worktree, "rm", "-f", "--ignore-unmatch", "--", stale], {
37901
+ allowFailure: true
37902
+ });
37903
+ if (stale.endsWith(".md")) {
37904
+ const staleUri = workfileToVikingUri(config2, team.config, (0, import_node_path3.join)(team.config.worktree, ...stale.split("/")));
37905
+ try {
37906
+ if (await vikingResourceExists(ov, config2, staleUri)) {
37907
+ await removeMemoryUri(config2, ov, staleUri, dryRun, { quiet: true });
37908
+ }
37909
+ } catch (pruneErr) {
37910
+ messages.push(
37911
+ `Warning: could not remove stale OpenViking resource ${staleUri}: ${pruneErr instanceof Error ? pruneErr.message : String(pruneErr)}`
37912
+ );
37913
+ }
37914
+ }
37915
+ }
37916
+ } catch (publishErr) {
37917
+ for (const undo of rollbacks.reverse()) {
37918
+ try {
37919
+ await undo();
37920
+ } catch (_cleanupErr) {
37921
+ }
37922
+ }
37923
+ throw publishErr;
37924
+ }
37925
+ const stagedPaths = [
37926
+ indexRelative,
37927
+ manifestRelative,
37928
+ ...prepared.map((entry) => `${filesRelative}/${entry.relativePath}`)
37929
+ ];
37930
+ const message = options.message ?? `share: publish pack ${artifact.agent}/${artifact.name} (${prepared.length} files)`;
37931
+ const gitMessages = await publishShareGitChange(team.config.worktree, stagedPaths, message, {
37547
37932
  dryRun,
37548
37933
  push: options.push
37549
37934
  });
37550
- return { artifact, gitMessages, messages, sourcePath: resolvedSourcePath, targetPath, targetUri };
37935
+ return {
37936
+ artifact,
37937
+ gitMessages,
37938
+ messages,
37939
+ sourcePath: resolvedManifest,
37940
+ targetPath: indexTargetPath,
37941
+ targetUri: indexTargetUri
37942
+ };
37551
37943
  }
37552
37944
  async function listSharedAgentArtifacts(config2, options = {}) {
37553
37945
  const syncResult = await maybeSyncSharedArtifacts(config2, options);
@@ -37590,6 +37982,10 @@ async function installSharedAgentArtifacts(config2, options) {
37590
37982
  }
37591
37983
  let installedCount = 0;
37592
37984
  for (const artifact of artifacts) {
37985
+ if (isBundleArtifact(artifact)) {
37986
+ installedCount += await installBundleArtifact(artifact, options, dryRun, messages);
37987
+ continue;
37988
+ }
37593
37989
  const label = sharedArtifactLabel(artifact.artifact);
37594
37990
  const state = await sharedArtifactInstallState(artifact);
37595
37991
  if (dryRun) {
@@ -37606,7 +38002,7 @@ async function installSharedAgentArtifacts(config2, options) {
37606
38002
  messages.push(`Already installed ${label}: ${portablePath(artifact.installPath)}`);
37607
38003
  continue;
37608
38004
  }
37609
- await ensureDirectory((0, import_node_path4.dirname)(artifact.installPath), false);
38005
+ await ensureDirectory((0, import_node_path3.dirname)(artifact.installPath), false);
37610
38006
  await (0, import_promises4.writeFile)(artifact.installPath, state.sourceContent, { encoding: "utf8", mode: 384 });
37611
38007
  await writeSharedArtifactMetadata(artifact, state.sourceSha);
37612
38008
  installedCount += 1;
@@ -37635,10 +38031,10 @@ function normalizeTeamName(input) {
37635
38031
  return candidate;
37636
38032
  }
37637
38033
  function teamsFilePath(config2) {
37638
- return (0, import_node_path4.join)(config2.agentContextHome, "share", "teams.json");
38034
+ return (0, import_node_path3.join)(config2.agentContextHome, "share", "teams.json");
37639
38035
  }
37640
38036
  function teamWorktreePath(config2, team) {
37641
- return (0, import_node_path4.join)(
38037
+ return (0, import_node_path3.join)(
37642
38038
  config2.agentContextHome,
37643
38039
  "data",
37644
38040
  "viking",
@@ -37651,7 +38047,7 @@ function teamWorktreePath(config2, team) {
37651
38047
  );
37652
38048
  }
37653
38049
  function teamGitdirPath(config2, team) {
37654
- return (0, import_node_path4.join)(config2.agentContextHome, "share", "teams", `${team}.gitdir`);
38050
+ return (0, import_node_path3.join)(config2.agentContextHome, "share", "teams", `${team}.gitdir`);
37655
38051
  }
37656
38052
  async function readTeamsFile(config2) {
37657
38053
  const path = teamsFilePath(config2);
@@ -37707,7 +38103,7 @@ async function resolveTeam(config2, requested) {
37707
38103
  return { config: found, name: wantName };
37708
38104
  }
37709
38105
  function workfileToVikingUri(config2, team, filePath) {
37710
- const rel = (0, import_node_path4.relative)(team.worktree, filePath).split(import_node_path4.sep).join("/");
38106
+ const rel = (0, import_node_path3.relative)(team.worktree, filePath).split(import_node_path3.sep).join("/");
37711
38107
  return `viking://user/${uriSegment(config2.user)}/memories/${SHARED_SEGMENT}/${team.name}/${rel}`;
37712
38108
  }
37713
38109
  function isInSharedNamespace(config2, uri) {
@@ -37765,15 +38161,15 @@ async function isRegularFileNoSymlink(path) {
37765
38161
  }
37766
38162
  }
37767
38163
  function inferShareArtifact(path, options) {
37768
- const normalizedPath = path.split(import_node_path4.sep).join("/");
37769
- const fileName = (0, import_node_path4.basename)(path);
38164
+ const normalizedPath = path.split(import_node_path3.sep).join("/");
38165
+ const fileName = (0, import_node_path3.basename)(path);
37770
38166
  const lowerFileName = fileName.toLowerCase();
37771
38167
  const lowerPath = normalizedPath.toLowerCase();
37772
38168
  const inferredKind = lowerFileName === "skill.md" ? "skill" : lowerPath.includes("/.claude/commands/") && lowerFileName.endsWith(".md") ? "command" : void 0;
37773
38169
  const inferredAgent = lowerPath.includes("/.codex/skills/") ? "codex" : lowerPath.includes("/.claude/skills/") || lowerPath.includes("/.claude/commands/") ? "claude" : void 0;
37774
38170
  const extensionIndex = fileName.lastIndexOf(".");
37775
38171
  const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
37776
- const inferredName = lowerFileName === "skill.md" ? (0, import_node_path4.basename)((0, import_node_path4.dirname)(path)) : stem;
38172
+ const inferredName = lowerFileName === "skill.md" ? (0, import_node_path3.basename)((0, import_node_path3.dirname)(path)) : stem;
37777
38173
  const kind = options.kind ?? inferredKind;
37778
38174
  const agent = options.agent ?? inferredAgent;
37779
38175
  const name = options.name ?? inferredName;
@@ -37814,10 +38210,13 @@ function sharedArtifactFromRelativePath(relativePath) {
37814
38210
  if (parts.length === 4 && parts[1] === "commands" && parts[2] === "claude" && parts[3].endsWith(".md")) {
37815
38211
  return { agent: "claude", kind: "command", name: parts[3].slice(0, -".md".length) };
37816
38212
  }
38213
+ if (parts.length === 5 && parts[1] === "packs" && (parts[2] === "codex" || parts[2] === "claude") && parts[4] === `${parts[3]}${PACK_INDEX_SUFFIX}`) {
38214
+ return { agent: parts[2], kind: "pack", name: parts[3] };
38215
+ }
37817
38216
  return void 0;
37818
38217
  }
37819
38218
  async function collectSharedArtifacts(worktree, team) {
37820
- const root = (0, import_node_path4.join)(worktree, SHAREABLE_ARTIFACT_DIR);
38219
+ const root = (0, import_node_path3.join)(worktree, SHAREABLE_ARTIFACT_DIR);
37821
38220
  if (!await isDirectory(root)) {
37822
38221
  return [];
37823
38222
  }
@@ -37825,7 +38224,7 @@ async function collectSharedArtifacts(worktree, team) {
37825
38224
  async function visit(path) {
37826
38225
  const entries = await (0, import_promises4.readdir)(path, { withFileTypes: true });
37827
38226
  for (const entry of entries) {
37828
- const full = (0, import_node_path4.join)(path, entry.name);
38227
+ const full = (0, import_node_path3.join)(path, entry.name);
37829
38228
  if (entry.isDirectory()) {
37830
38229
  await visit(full);
37831
38230
  continue;
@@ -37833,23 +38232,100 @@ async function collectSharedArtifacts(worktree, team) {
37833
38232
  if (!entry.isFile() || !entry.name.endsWith(".md")) {
37834
38233
  continue;
37835
38234
  }
37836
- const relativePath = (0, import_node_path4.relative)(worktree, full).split(import_node_path4.sep).join("/");
38235
+ const relativePath = (0, import_node_path3.relative)(worktree, full).split(import_node_path3.sep).join("/");
37837
38236
  const artifact = sharedArtifactFromRelativePath(relativePath);
37838
38237
  if (artifact === void 0) {
37839
38238
  continue;
37840
38239
  }
37841
- out.push({
37842
- artifact,
37843
- installPath: sharedArtifactInstallPath(team, artifact),
37844
- sourcePath: full,
37845
- sourceRelativePath: relativePath,
37846
- team
37847
- });
37848
- }
38240
+ const artifactDir = (0, import_node_path3.dirname)(full);
38241
+ if (artifact.kind === "pack" && !await isFile((0, import_node_path3.join)(artifactDir, `${artifact.name}${PACK_MANIFEST_SUFFIX}`))) {
38242
+ console.warn(
38243
+ `Skipping incomplete shared pack (missing ${artifact.name}${PACK_MANIFEST_SUFFIX}): ${relativePath}`
38244
+ );
38245
+ continue;
38246
+ }
38247
+ try {
38248
+ out.push({
38249
+ artifact,
38250
+ installPath: sharedArtifactInstallPath(team, artifact),
38251
+ members: await collectArtifactMembers(artifact, artifactDir),
38252
+ sourcePath: full,
38253
+ sourceRelativePath: relativePath,
38254
+ team
38255
+ });
38256
+ } catch (err) {
38257
+ console.warn(`Skipping shared artifact ${relativePath}: ${err instanceof Error ? err.message : String(err)}`);
38258
+ }
38259
+ }
37849
38260
  }
37850
38261
  await visit(root);
37851
38262
  return out.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
37852
38263
  }
38264
+ async function collectArtifactMembers(artifact, artifactDir) {
38265
+ if (artifact.kind === "skill") {
38266
+ return collectSharedBundleMembers(artifactDir);
38267
+ }
38268
+ if (artifact.kind === "pack") {
38269
+ return collectSharedPackMembers(artifact, artifactDir);
38270
+ }
38271
+ return void 0;
38272
+ }
38273
+ function isContainedMemberPath(baseDir, relativePath) {
38274
+ if ((0, import_node_path3.isAbsolute)(relativePath) || relativePath.split("/").includes("..")) {
38275
+ return false;
38276
+ }
38277
+ const resolved = (0, import_node_path3.join)(baseDir, ...relativePath.split("/"));
38278
+ return resolved === baseDir || resolved.startsWith(baseDir + import_node_path3.sep);
38279
+ }
38280
+ async function collectSharedPackMembers(artifact, packDir) {
38281
+ const filesDir = (0, import_node_path3.join)(packDir, PACK_FILES_DIR);
38282
+ if (!await isDirectory(filesDir)) {
38283
+ return [];
38284
+ }
38285
+ const manifestRaw = await readFileIfExists((0, import_node_path3.join)(packDir, `${artifact.name}${PACK_MANIFEST_SUFFIX}`));
38286
+ if (manifestRaw !== void 0) {
38287
+ const rawMembers = parseJsonConfigObject(manifestRaw)?.members;
38288
+ if (Array.isArray(rawMembers)) {
38289
+ const fromManifest = [];
38290
+ for (const entry of rawMembers) {
38291
+ const path = entry?.path;
38292
+ if (typeof path === "string" && path.length > 0) {
38293
+ if (!isContainedMemberPath(filesDir, path)) {
38294
+ throw new Error(`Refusing pack member with an unsafe path that escapes the pack root: ${path}`);
38295
+ }
38296
+ fromManifest.push({ absolutePath: (0, import_node_path3.join)(filesDir, ...path.split("/")), relativePath: path });
38297
+ }
38298
+ }
38299
+ if (fromManifest.length > 0) {
38300
+ return fromManifest.sort((a, b) => compareStrings(a.relativePath, b.relativePath));
38301
+ }
38302
+ }
38303
+ }
38304
+ return collectBundleMemberFiles(filesDir);
38305
+ }
38306
+ async function collectSharedBundleMembers(skillDir) {
38307
+ const manifestRaw = await readFileIfExists((0, import_node_path3.join)(skillDir, BUNDLE_MANIFEST_FILE));
38308
+ if (manifestRaw !== void 0) {
38309
+ const parsed = parseJsonConfigObject(manifestRaw);
38310
+ const rawMembers = parsed?.members;
38311
+ if (Array.isArray(rawMembers)) {
38312
+ const fromManifest = [];
38313
+ for (const entry of rawMembers) {
38314
+ const path = entry?.path;
38315
+ if (typeof path === "string" && path.length > 0) {
38316
+ if (!isContainedMemberPath(skillDir, path)) {
38317
+ throw new Error(`Refusing skill member with an unsafe path that escapes the skill root: ${path}`);
38318
+ }
38319
+ fromManifest.push({ absolutePath: (0, import_node_path3.join)(skillDir, ...path.split("/")), relativePath: path });
38320
+ }
38321
+ }
38322
+ if (fromManifest.length > 0) {
38323
+ return fromManifest.sort((a, b) => compareStrings(a.relativePath, b.relativePath));
38324
+ }
38325
+ }
38326
+ }
38327
+ return collectBundleMemberFiles(skillDir);
38328
+ }
37853
38329
  function filterSharedArtifacts(artifacts, options) {
37854
38330
  const name = options.name === void 0 ? void 0 : uriSegment(options.name);
37855
38331
  return artifacts.filter((artifact) => {
@@ -37872,8 +38348,223 @@ async function maybeSyncSharedArtifacts(config2, options) {
37872
38348
  return syncSharedReposBeforeAgentRead(config2);
37873
38349
  }
37874
38350
  async function sharedArtifactInstallStatus(artifact) {
38351
+ if (isBundleArtifact(artifact)) {
38352
+ return sharedBundleInstallStatus(artifact);
38353
+ }
37875
38354
  return (await sharedArtifactInstallState(artifact)).status;
37876
38355
  }
38356
+ function bundleInstallRoot(artifact) {
38357
+ return artifact.artifact.kind === "pack" ? artifact.installPath : (0, import_node_path3.dirname)(artifact.installPath);
38358
+ }
38359
+ function bundleInstallMetadataPath(artifact) {
38360
+ return (0, import_node_path3.join)(bundleInstallRoot(artifact), BUNDLE_INSTALL_METADATA_FILE);
38361
+ }
38362
+ async function readBundleInstallMetadata(artifact) {
38363
+ const raw = await readFileIfExists(bundleInstallMetadataPath(artifact));
38364
+ if (raw === void 0) {
38365
+ return void 0;
38366
+ }
38367
+ const parsed = parseJsonConfigObject(raw);
38368
+ if (parsed === void 0 || parsed.version !== ARTIFACT_INSTALL_METADATA_VERSION || !Array.isArray(parsed.members)) {
38369
+ return void 0;
38370
+ }
38371
+ const recordedArtifact = parsed.artifact;
38372
+ if (recordedArtifact?.agent !== artifact.artifact.agent || recordedArtifact?.kind !== artifact.artifact.kind || recordedArtifact?.name !== artifact.artifact.name || parsed.team !== artifact.team) {
38373
+ return void 0;
38374
+ }
38375
+ const map3 = /* @__PURE__ */ new Map();
38376
+ for (const entry of parsed.members) {
38377
+ const path = entry?.path;
38378
+ const sourceSha256 = entry?.sourceSha256;
38379
+ const installedSha256 = entry?.installedSha256;
38380
+ if (typeof path === "string" && typeof sourceSha256 === "string" && typeof installedSha256 === "string") {
38381
+ map3.set(path, { installedSha256, sourceSha256 });
38382
+ }
38383
+ }
38384
+ return map3;
38385
+ }
38386
+ async function sharedBundleInstallStatus(artifact) {
38387
+ const members = artifact.members ?? [];
38388
+ const installRoot = bundleInstallRoot(artifact);
38389
+ const metadata = await readBundleInstallMetadata(artifact);
38390
+ const expanded = await prepareInstallMembers(members, installRoot, artifact.artifact.kind === "pack");
38391
+ const expectedByPath = new Map(expanded.map((entry) => [entry.relativePath, entry]));
38392
+ let installedCount = 0;
38393
+ let localChanged = false;
38394
+ let remoteChanged = false;
38395
+ const memberPaths = /* @__PURE__ */ new Set();
38396
+ for (const member of members) {
38397
+ memberPaths.add(member.relativePath);
38398
+ const expected = expectedByPath.get(member.relativePath);
38399
+ if (expected === void 0) {
38400
+ remoteChanged = true;
38401
+ continue;
38402
+ }
38403
+ const installedBytes = await readFileBytesIfExists((0, import_node_path3.join)(installRoot, ...member.relativePath.split("/")));
38404
+ if (installedBytes === void 0) {
38405
+ remoteChanged = true;
38406
+ continue;
38407
+ }
38408
+ installedCount += 1;
38409
+ const installedSha = sha256(installedBytes);
38410
+ const recorded = metadata?.get(member.relativePath);
38411
+ if (recorded === void 0) {
38412
+ if (installedSha !== expected?.installedSha256) {
38413
+ localChanged = true;
38414
+ }
38415
+ continue;
38416
+ }
38417
+ if (installedSha !== recorded.installedSha256) {
38418
+ localChanged = true;
38419
+ }
38420
+ if (expected?.sourceSha256 !== recorded.sourceSha256) {
38421
+ remoteChanged = true;
38422
+ }
38423
+ }
38424
+ if (metadata !== void 0) {
38425
+ for (const [recordedPath, recorded] of metadata) {
38426
+ if (memberPaths.has(recordedPath)) {
38427
+ continue;
38428
+ }
38429
+ remoteChanged = true;
38430
+ const installedBytes = await readFileBytesIfExists((0, import_node_path3.join)(installRoot, ...recordedPath.split("/")));
38431
+ if (installedBytes !== void 0 && sha256(installedBytes) !== recorded.installedSha256) {
38432
+ localChanged = true;
38433
+ }
38434
+ }
38435
+ }
38436
+ if (installedCount === 0 && metadata === void 0) {
38437
+ return "not_installed";
38438
+ }
38439
+ if (localChanged && remoteChanged) {
38440
+ return "remote_changed_and_local_modified";
38441
+ }
38442
+ if (remoteChanged) {
38443
+ return "update_available";
38444
+ }
38445
+ if (localChanged) {
38446
+ return "local_modified";
38447
+ }
38448
+ return "current";
38449
+ }
38450
+ function expandPackRoot(text, installRoot) {
38451
+ return text.split(PACK_ROOT_TOKEN).join(installRoot);
38452
+ }
38453
+ async function prepareInstallMembers(members, installRoot, expandTokens) {
38454
+ const prepared = await Promise.all(
38455
+ members.map(async (member) => {
38456
+ const sourceBytes = await readFileBytesIfExists(member.absolutePath);
38457
+ if (sourceBytes === void 0) {
38458
+ return void 0;
38459
+ }
38460
+ const installedBytes = expandTokens && !isProbablyBinary(sourceBytes) ? Buffer.from(expandPackRoot(sourceBytes.toString("utf8"), installRoot), "utf8") : sourceBytes;
38461
+ return {
38462
+ installedBytes,
38463
+ installedSha256: sha256(installedBytes),
38464
+ relativePath: member.relativePath,
38465
+ sourceSha256: sha256(sourceBytes)
38466
+ };
38467
+ })
38468
+ );
38469
+ return prepared.filter((member) => member !== void 0);
38470
+ }
38471
+ function serializeInstallMetadata(artifact, prepared) {
38472
+ const metadata = {
38473
+ artifact: artifact.artifact,
38474
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
38475
+ members: prepared.map((entry) => ({
38476
+ installedSha256: entry.installedSha256,
38477
+ path: entry.relativePath,
38478
+ sourceSha256: entry.sourceSha256
38479
+ })).sort((a, b) => compareStrings(a.path, b.path)),
38480
+ team: artifact.team,
38481
+ version: ARTIFACT_INSTALL_METADATA_VERSION
38482
+ };
38483
+ return `${JSON.stringify(metadata, void 0, 2)}
38484
+ `;
38485
+ }
38486
+ async function installBundleArtifact(artifact, options, dryRun, messages) {
38487
+ const members = artifact.members ?? [];
38488
+ const installRoot = bundleInstallRoot(artifact);
38489
+ const kindLabel = artifact.artifact.kind === "pack" ? "pack" : "bundle";
38490
+ const label = `${sharedArtifactLabel(artifact.artifact)} ${kindLabel} (${members.length} files)`;
38491
+ const status = await sharedBundleInstallStatus(artifact);
38492
+ if (dryRun) {
38493
+ const verb = sharedArtifactDryRunVerb(status, options.force === true);
38494
+ const suffix = sharedArtifactDryRunSuffix(status, options.force === true);
38495
+ messages.push(`${verb} ${label}: ${portablePath(installRoot)}${suffix}`);
38496
+ return 0;
38497
+ }
38498
+ if ((status === "local_modified" || status === "remote_changed_and_local_modified") && options.force !== true) {
38499
+ throw new Error(`Refusing to overwrite ${portablePath(installRoot)}. Pass force=true or --force.`);
38500
+ }
38501
+ const prepared = await prepareInstallMembers(members, installRoot, artifact.artifact.kind === "pack");
38502
+ if (prepared.length < members.length && options.force !== true) {
38503
+ throw new Error(
38504
+ `Refusing to install ${portablePath(installRoot)}: ${members.length - prepared.length} declared member(s) are unreadable in the shared pack (the shared worktree may be mid-sync). Retry after sync, or pass force=true / --force.`
38505
+ );
38506
+ }
38507
+ if (status === "current") {
38508
+ await (0, import_promises4.writeFile)(bundleInstallMetadataPath(artifact), serializeInstallMetadata(artifact, prepared), { mode: 384 });
38509
+ messages.push(`Already installed ${label}: ${portablePath(installRoot)}`);
38510
+ await surfacePackRequirements(artifact, messages);
38511
+ return 0;
38512
+ }
38513
+ const stagingRoot = `${installRoot}.threadnote-staging`;
38514
+ await (0, import_promises4.rm)(stagingRoot, { force: true, recursive: true });
38515
+ for (const entry of prepared) {
38516
+ const dest = (0, import_node_path3.join)(stagingRoot, ...entry.relativePath.split("/"));
38517
+ await ensureDirectory((0, import_node_path3.dirname)(dest), false);
38518
+ await (0, import_promises4.writeFile)(dest, entry.installedBytes, { mode: 384 });
38519
+ }
38520
+ await (0, import_promises4.writeFile)((0, import_node_path3.join)(stagingRoot, BUNDLE_INSTALL_METADATA_FILE), serializeInstallMetadata(artifact, prepared), {
38521
+ mode: 384
38522
+ });
38523
+ await ensureDirectory((0, import_node_path3.dirname)(installRoot), false);
38524
+ const backupRoot = `${installRoot}.threadnote-old`;
38525
+ await (0, import_promises4.rm)(backupRoot, { force: true, recursive: true });
38526
+ const hadPriorInstall = await exists(installRoot);
38527
+ if (hadPriorInstall) {
38528
+ await (0, import_promises4.rename)(installRoot, backupRoot);
38529
+ }
38530
+ try {
38531
+ await (0, import_promises4.rename)(stagingRoot, installRoot);
38532
+ } catch (swapErr) {
38533
+ if (hadPriorInstall) {
38534
+ await (0, import_promises4.rename)(backupRoot, installRoot);
38535
+ }
38536
+ throw swapErr;
38537
+ }
38538
+ await (0, import_promises4.rm)(backupRoot, { force: true, recursive: true });
38539
+ messages.push(`${sharedArtifactInstallVerb(status, options.force === true)} ${label}: ${portablePath(installRoot)}`);
38540
+ await surfacePackRequirements(artifact, messages);
38541
+ return 1;
38542
+ }
38543
+ async function surfacePackRequirements(artifact, messages) {
38544
+ if (artifact.artifact.kind !== "pack") {
38545
+ return;
38546
+ }
38547
+ const raw = await readFileIfExists(
38548
+ (0, import_node_path3.join)((0, import_node_path3.dirname)(artifact.sourcePath), `${artifact.artifact.name}${PACK_MANIFEST_SUFFIX}`)
38549
+ );
38550
+ if (raw === void 0) {
38551
+ return;
38552
+ }
38553
+ const deps = parseJsonConfigObject(raw)?.deps;
38554
+ if (deps === void 0 || typeof deps !== "object") {
38555
+ return;
38556
+ }
38557
+ const stringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
38558
+ const depsRecord = deps;
38559
+ const tooling = [...stringList(depsRecord.runtime), ...stringList(depsRecord.cli), ...stringList(depsRecord.os)];
38560
+ const mcp = stringList(depsRecord.mcp);
38561
+ if (tooling.length > 0) {
38562
+ messages.push(`This pack will NOT run until these exist (Threadnote installs files only): ${tooling.join(", ")}.`);
38563
+ }
38564
+ if (mcp.length > 0) {
38565
+ messages.push(`Configure these MCP server(s) separately: ${mcp.join(", ")}.`);
38566
+ }
38567
+ }
37877
38568
  async function sharedArtifactInstallState(artifact) {
37878
38569
  const sourceContent = await (0, import_promises4.readFile)(artifact.sourcePath, "utf8");
37879
38570
  const sourceSha = sha256(sourceContent);
@@ -37944,7 +38635,7 @@ async function writeSharedArtifactMetadata(artifact, sourceSha) {
37944
38635
  version: ARTIFACT_INSTALL_METADATA_VERSION
37945
38636
  };
37946
38637
  const metadataPath = sharedArtifactMetadataPath(artifact);
37947
- await ensureDirectory((0, import_node_path4.dirname)(metadataPath), false);
38638
+ await ensureDirectory((0, import_node_path3.dirname)(metadataPath), false);
37948
38639
  await (0, import_promises4.writeFile)(metadataPath, `${JSON.stringify(metadata, void 0, 2)}
37949
38640
  `, { encoding: "utf8", mode: 384 });
37950
38641
  }
@@ -37996,336 +38687,834 @@ function sharedArtifactLabel(artifact) {
37996
38687
  return `${artifact.kind} ${artifact.agent}/${artifact.name}`;
37997
38688
  }
37998
38689
  function sharedArtifactInstallPath(team, artifact) {
37999
- if (artifact.kind === "skill" && artifact.agent === "codex") {
38000
- return (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".codex", "skills", "threadnote", team, artifact.name, "SKILL.md");
38690
+ const agentDir = artifact.agent === "codex" ? ".codex" : ".claude";
38691
+ if (artifact.kind === "pack") {
38692
+ return (0, import_node_path3.join)((0, import_node_os2.homedir)(), agentDir, "skills", "threadnote-packs", team, artifact.name);
38693
+ }
38694
+ if (artifact.kind === "skill") {
38695
+ return (0, import_node_path3.join)((0, import_node_os2.homedir)(), agentDir, "skills", "threadnote", team, artifact.name, "SKILL.md");
38696
+ }
38697
+ return (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".claude", "commands", "threadnote", team, `${artifact.name}.md`);
38698
+ }
38699
+ function stripPersonalProvenance(content) {
38700
+ const lines = content.split("\n");
38701
+ let headerEnd = lines.length;
38702
+ for (let index = 0; index < lines.length; index += 1) {
38703
+ if (lines[index].trim() === "") {
38704
+ headerEnd = index;
38705
+ break;
38706
+ }
38707
+ }
38708
+ const cleaned = [];
38709
+ for (let index = 0; index < headerEnd; index += 1) {
38710
+ if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
38711
+ continue;
38712
+ }
38713
+ cleaned.push(lines[index]);
38001
38714
  }
38002
- if (artifact.kind === "skill" && artifact.agent === "claude") {
38003
- return (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".claude", "skills", "threadnote", team, artifact.name, "SKILL.md");
38715
+ for (let index = headerEnd; index < lines.length; index += 1) {
38716
+ cleaned.push(lines[index]);
38717
+ }
38718
+ return cleaned.join("\n");
38719
+ }
38720
+ async function ensureSharedDirectoryChain(config2, ov, memoryUri, dryRun, options = {}) {
38721
+ const directoryUri = parentUri(memoryUri);
38722
+ for (const uri of sharedDirectoryChain(config2, directoryUri)) {
38723
+ const args = withIdentity(config2, ["stat", uri]);
38724
+ if (dryRun) {
38725
+ if (options.quiet !== true) {
38726
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
38727
+ }
38728
+ continue;
38729
+ }
38730
+ const statResult = await runCommand(ov, args, { allowFailure: true });
38731
+ if (statResult.exitCode === 0) {
38732
+ continue;
38733
+ }
38734
+ if (options.quiet === true) {
38735
+ await runCommand(ov, withIdentity(config2, ["mkdir", uri, "--description", "Threadnote shared context."]));
38736
+ } else {
38737
+ await maybeRun(false, ov, withIdentity(config2, ["mkdir", uri, "--description", "Threadnote shared context."]));
38738
+ }
38739
+ }
38740
+ }
38741
+ function parentUri(uri) {
38742
+ const lastSlash = uri.lastIndexOf("/");
38743
+ return lastSlash === -1 ? uri : uri.slice(0, lastSlash);
38744
+ }
38745
+ function sharedDirectoryChain(config2, directoryUri) {
38746
+ const prefix = `viking://user/${uriSegment(config2.user)}/memories/${SHARED_SEGMENT}/`;
38747
+ if (!directoryUri.startsWith(prefix)) {
38748
+ return [directoryUri];
38749
+ }
38750
+ const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
38751
+ const chain = [];
38752
+ for (let index = 1; index <= parts.length; index += 1) {
38753
+ chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
38754
+ }
38755
+ return chain;
38756
+ }
38757
+ async function writeMemoryFile(config2, ov, uri, content, initialMode, dryRun, options = {}) {
38758
+ if (dryRun) {
38759
+ const args = withIdentity(config2, [
38760
+ "write",
38761
+ uri,
38762
+ "--from-file",
38763
+ "<staged temp file>",
38764
+ "--mode",
38765
+ initialMode,
38766
+ "--wait",
38767
+ "--timeout",
38768
+ "120"
38769
+ ]);
38770
+ if (options.quiet !== true) {
38771
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
38772
+ }
38773
+ return;
38774
+ }
38775
+ const stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "threadnote-share-"));
38776
+ const tempPath = (0, import_node_path3.join)(stagingDir, "body.txt");
38777
+ try {
38778
+ await (0, import_promises4.writeFile)(tempPath, content, { encoding: "utf8", mode: 384 });
38779
+ await writeOvFileWithRetry(config2, ov, uri, tempPath, initialMode, options);
38780
+ await refreshMemoryIndex(config2, ov, uri, options);
38781
+ } finally {
38782
+ await (0, import_promises4.rm)(stagingDir, { force: true, recursive: true });
38783
+ }
38784
+ }
38785
+ var BUSY_RETRY_BACKOFF_MS = [2e3, 5e3, 1e4, 2e4, 3e4];
38786
+ async function writeOvFileWithRetry(config2, ov, uri, fromFile, initialMode, options = {}) {
38787
+ const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38788
+ const existedBeforeWrite = await vikingResourceExists(ov, config2, uri);
38789
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38790
+ const existsNow = attempt === 0 ? existedBeforeWrite : await vikingResourceExists(ov, config2, uri);
38791
+ const ourWriteLanded = existsNow && !existedBeforeWrite;
38792
+ const mode = attempt === 0 ? initialMode : ourWriteLanded ? "replace" : initialMode;
38793
+ const args = withIdentity(config2, [
38794
+ "write",
38795
+ uri,
38796
+ "--from-file",
38797
+ fromFile,
38798
+ "--mode",
38799
+ mode,
38800
+ "--wait",
38801
+ "--timeout",
38802
+ "120"
38803
+ ]);
38804
+ if (options.quiet !== true) {
38805
+ console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
38806
+ }
38807
+ const result = await runCommand(ov, args, { allowFailure: true });
38808
+ if (result.exitCode === 0) {
38809
+ if (options.quiet !== true && result.stdout.trim()) {
38810
+ console.log(result.stdout.trim());
38811
+ }
38812
+ if (options.quiet !== true && result.stderr.trim()) {
38813
+ console.error(result.stderr.trim());
38814
+ }
38815
+ return;
38816
+ }
38817
+ if (isTransientOvFailure(result.stderr, result.stdout) && await vikingResourceExists(ov, config2, uri) && !existedBeforeWrite) {
38818
+ if (options.quiet !== true) {
38819
+ console.log(
38820
+ "OpenViking accepted the write but returned an error before the wait completed; draining the queue."
38821
+ );
38822
+ }
38823
+ await waitForOvQueue(ov, config2, options);
38824
+ return;
38825
+ }
38826
+ if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38827
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38828
+ }
38829
+ if (isResourceBusyFailure(result.stderr, result.stdout)) {
38830
+ await waitForOvQueue(ov, config2, options);
38831
+ await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38832
+ } else {
38833
+ await sleep(1e3 * (attempt + 1));
38834
+ }
38835
+ }
38836
+ }
38837
+ async function refreshMemoryIndex(config2, ov, uri, options = {}) {
38838
+ const result = await runCommand(
38839
+ ov,
38840
+ withIdentity(config2, ["reindex", uri, "--mode", "semantic_and_vectors", "--wait", "true"]),
38841
+ { allowFailure: true }
38842
+ );
38843
+ if (result.exitCode === 0) {
38844
+ if (options.quiet !== true && result.stdout.trim()) {
38845
+ console.log(result.stdout.trim());
38846
+ }
38847
+ if (options.quiet !== true && result.stderr.trim()) {
38848
+ console.error(result.stderr.trim());
38849
+ }
38850
+ return;
38851
+ }
38852
+ if (options.quiet !== true) {
38853
+ console.error(
38854
+ `Memory stored, but index refresh failed for ${uri}: ${result.stderr.trim() || result.stdout.trim()}`
38855
+ );
38856
+ }
38857
+ }
38858
+ async function waitForOvQueue(ov, config2, options = {}) {
38859
+ const result = await runCommand(ov, withIdentity(config2, ["wait", "--timeout", "120"]), { allowFailure: true });
38860
+ if (options.quiet !== true && result.stdout.trim()) {
38861
+ console.log(result.stdout.trim());
38862
+ }
38863
+ if (options.quiet !== true && result.stderr.trim()) {
38864
+ console.error(result.stderr.trim());
38865
+ }
38866
+ }
38867
+ function isTransientOvFailure(stderr, stdout2) {
38868
+ const output = `${stderr}
38869
+ ${stdout2}`.toLowerCase();
38870
+ return output.includes("resource is busy") || output.includes("resource is being processed") || output.includes("network error") || output.includes("error sending request") || output.includes("http request failed") || output.includes("connection refused") || output.includes("connection reset") || output.includes("timed out");
38871
+ }
38872
+ function isResourceBusyFailure(stderr, stdout2) {
38873
+ const output = `${stderr}
38874
+ ${stdout2}`.toLowerCase();
38875
+ return output.includes("resource is busy") || output.includes("resource is being processed");
38876
+ }
38877
+ async function ingestSingleFile(ov, config2, uri, filePath, initialMode, options = {}) {
38878
+ const content = await (0, import_promises4.readFile)(filePath, "utf8");
38879
+ await writeMemoryFile(config2, ov, uri, content, initialMode, false, options);
38880
+ }
38881
+ async function removeMemoryUri(config2, ov, uri, dryRun, options = {}) {
38882
+ const args = withIdentity(config2, ["rm", uri]);
38883
+ if (dryRun) {
38884
+ if (options.quiet !== true) {
38885
+ console.log(`Would run: ${formatShellCommand(ov, args)}`);
38886
+ }
38887
+ return;
38888
+ }
38889
+ const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38890
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38891
+ const result = await runCommand(ov, args, { allowFailure: true });
38892
+ if (result.exitCode === 0) {
38893
+ if (options.quiet !== true && result.stdout.trim()) {
38894
+ console.log(result.stdout.trim());
38895
+ }
38896
+ return;
38897
+ }
38898
+ if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38899
+ throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38900
+ }
38901
+ if (isResourceBusyFailure(result.stderr, result.stdout)) {
38902
+ await waitForOvQueue(ov, config2, options);
38903
+ await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38904
+ } else {
38905
+ await sleep(1e3 * (attempt + 1));
38906
+ }
38907
+ }
38908
+ }
38909
+ async function vikingResourceExists(ov, config2, uri) {
38910
+ const result = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
38911
+ return result.exitCode === 0;
38912
+ }
38913
+ async function hasUncommittedChanges(worktree) {
38914
+ const result = await runCommand("git", ["-C", worktree, "status", "--porcelain"], { allowFailure: true });
38915
+ return result.stdout.trim().length > 0;
38916
+ }
38917
+ async function gitOutput(worktree, args, dryRun) {
38918
+ if (dryRun) {
38919
+ return void 0;
38920
+ }
38921
+ const result = await runCommand("git", ["-C", worktree, ...args], { allowFailure: true });
38922
+ if (result.exitCode !== 0) {
38923
+ return void 0;
38924
+ }
38925
+ return result.stdout.trim();
38926
+ }
38927
+ async function listChangedFiles(worktree, beforeRev, afterRev) {
38928
+ const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
38929
+ allowFailure: true
38930
+ });
38931
+ if (result.exitCode !== 0) {
38932
+ return [];
38933
+ }
38934
+ const entries = result.stdout.split("\0").filter((part) => part.length > 0);
38935
+ const changes = [];
38936
+ for (let index = 0; index < entries.length; ) {
38937
+ const raw = entries[index];
38938
+ const head = raw.slice(0, 1);
38939
+ if (head === "R" || head === "C") {
38940
+ const oldRel = entries[index + 1];
38941
+ const newRel = entries[index + 2];
38942
+ if (oldRel && newRel) {
38943
+ changes.push({ path: (0, import_node_path3.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
38944
+ changes.push({ path: (0, import_node_path3.join)(worktree, newRel), relativePath: newRel, status: "added" });
38945
+ }
38946
+ index += 3;
38947
+ continue;
38948
+ }
38949
+ const rel = entries[index + 1];
38950
+ if (rel) {
38951
+ const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
38952
+ changes.push({ path: (0, import_node_path3.join)(worktree, rel), relativePath: rel, status });
38953
+ }
38954
+ index += 2;
38955
+ }
38956
+ return changes;
38957
+ }
38958
+ async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38959
+ const ov = await openVikingCliForMode(false);
38960
+ const failed = [];
38961
+ for (const change of changes) {
38962
+ if (!change.relativePath.endsWith(".md")) {
38963
+ continue;
38964
+ }
38965
+ const firstSegment = change.relativePath.split("/")[0];
38966
+ if (!SHAREABLE_TOP_LEVEL_DIRS.includes(firstSegment)) {
38967
+ continue;
38968
+ }
38969
+ const uri = workfileToVikingUri(config2, team, change.path);
38970
+ try {
38971
+ if (change.status === "removed") {
38972
+ await removeMemoryUri(config2, ov, uri, false, options);
38973
+ continue;
38974
+ }
38975
+ if (!await isFile(change.path)) {
38976
+ continue;
38977
+ }
38978
+ const ovHasResource = await vikingResourceExists(ov, config2, uri);
38979
+ if (ovHasResource) {
38980
+ const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
38981
+ if (options.quiet !== true) {
38982
+ console.warn(`share sync: ${uri}: ${reason}.`);
38983
+ }
38984
+ }
38985
+ await ensureSharedDirectoryChain(config2, ov, uri, false, options);
38986
+ const writeMode = ovHasResource ? "replace" : "create";
38987
+ await ingestSingleFile(ov, config2, uri, change.path, writeMode, options);
38988
+ } catch (err) {
38989
+ const message = err instanceof Error ? err.message : String(err);
38990
+ if (options.quiet !== true) {
38991
+ console.warn(`share sync: ${uri}: ingest failed \u2014 will retry on the next sync. ${message}`);
38992
+ }
38993
+ failed.push(change);
38994
+ }
38995
+ }
38996
+ return { failed };
38997
+ }
38998
+ function mergeChanges(...lists) {
38999
+ const map3 = /* @__PURE__ */ new Map();
39000
+ for (const list of lists) {
39001
+ for (const change of list) {
39002
+ map3.set(change.relativePath, change);
39003
+ }
39004
+ }
39005
+ return [...map3.values()];
39006
+ }
39007
+ async function applyAndPersistChanges(config2, team, state, changes, options = {}) {
39008
+ if (changes.length === 0) {
39009
+ return { failed: [] };
39010
+ }
39011
+ state.pendingReindexes.set(team.name, changes);
39012
+ await writePendingReindexes(config2, state);
39013
+ const result = await applyChangesToOpenViking(config2, team, changes, options);
39014
+ if (result.failed.length > 0) {
39015
+ state.pendingReindexes.set(team.name, result.failed);
39016
+ } else {
39017
+ state.pendingReindexes.delete(team.name);
39018
+ }
39019
+ await writePendingReindexes(config2, state);
39020
+ return result;
39021
+ }
39022
+
39023
+ // src/onboarding.ts
39024
+ async function gatherOnboardingContext(config2) {
39025
+ return { seededProjects: await safeSeededProjects(config2), teams: await safeTeams(config2) };
39026
+ }
39027
+ async function safeTeams(config2) {
39028
+ try {
39029
+ return Object.keys((await readTeamsFile(config2)).teams ?? {}).sort();
39030
+ } catch (_err) {
39031
+ return [];
39032
+ }
39033
+ }
39034
+ async function safeSeededProjects(config2) {
39035
+ try {
39036
+ return (await readSeedManifest(config2.manifestPath)).projects.map((project) => project.name);
39037
+ } catch (_err) {
39038
+ return [];
39039
+ }
39040
+ }
39041
+ function buildOnboardingGuide(state) {
39042
+ const serverLine = state.serverUp === false ? "OpenViking is NOT responding \u2014 recall/remember will fail until the user runs `threadnote start`. Offer to walk them through that first." : state.serverUp === true ? "OpenViking is running." : "OpenViking status unknown \u2014 if recall/remember return connection errors, the fix is `threadnote start`.";
39043
+ const teamLine = state.teams.length > 0 ? `Team sharing is configured: ${state.teams.join(", ")}. They can publish memories and skills to teammates.` : "No share team configured yet \u2014 team sharing is available but needs a one-time `threadnote share init <git-remote>`.";
39044
+ const seedLine = state.seededProjects.length > 0 ? `Seeded project guidance is available for: ${state.seededProjects.join(", ")} (recall surfaces it when the query names the project).` : "No seeded project guidance yet \u2014 recall draws on personal memories and shared team memories.";
39045
+ const hasTeam = state.teams.length > 0;
39046
+ return [
39047
+ "# Threadnote \u2014 what you can do here",
39048
+ "",
39049
+ "You are onboarding the user to Threadnote. Do NOT paste this list verbatim. Read it,",
39050
+ "then guide the user conversationally: lead with what is most useful given the state",
39051
+ "below, explain one capability in a sentence, and OFFER to run it for them (with their",
39052
+ "go-ahead) instead of only describing it. Start small.",
39053
+ "",
39054
+ "## Current setup",
39055
+ `- ${serverLine}`,
39056
+ `- ${teamLine}`,
39057
+ `- ${seedLine}`,
39058
+ "",
39059
+ "## Capabilities to offer",
39060
+ "",
39061
+ "Recall context \u2014 pull back the last handoff and durable knowledge for the current",
39062
+ "repo+branch before starting work, so the session continues where the last one left off.",
39063
+ ' Run: recall_context({"query":"<repo> latest handoff","callerCwd":"<abs cwd>"}), then',
39064
+ " read_context the most relevant viking:// URI it returns.",
39065
+ "",
39066
+ "Capture work \u2014 save a durable fact (a decision, interface, gotcha) or a handoff (current",
39067
+ "status + next step) so the next session or a teammate inherits it.",
39068
+ ' Run: remember_context({"text":"...","kind":"durable","project":"<repo>","topic":"<feature>"})',
39069
+ ' or a handoff with kind:"handoff". Reuse the same project/topic to keep one memory current.',
39070
+ "",
39071
+ "Tidy memory \u2014 when recall surfaces overlapping notes for one topic, preview a scoped merge.",
39072
+ ' Run: compact_context({"project":"<repo>","topic":"<topic>","dryRun":true}) and review before applying.',
39073
+ "",
39074
+ "Share with your team \u2014 publish a durable memory teammates\u2019 agents can recall. Secrets are",
39075
+ "scrubbed/blocked; handoffs/preferences/local-path notes are never shared.",
39076
+ hasTeam ? ' Run: share_publish({"uri":"viking://user/<you>/memories/durable/projects/<p>/<m>.md"}).' : ' First (one-time): `threadnote share init git@github.com:org/team-memories.git`, then share_publish({"uri":"..."}).',
39077
+ "",
39078
+ "Share skills & packs \u2014 publish a Codex/Claude skill, or a multi-skill pack (skills + shared",
39079
+ "scripts), into the team catalog; teammates install them on demand.",
39080
+ ' Run: share_skill({"path":"~/.claude/skills/<name>/SKILL.md"}) or',
39081
+ ' share_bundle({"path":"<repo>/threadnote-bundle.json"}). Teammates: list_shared_skills({})',
39082
+ ' then install_shared_skill({"name":"<name>"}).',
39083
+ "",
39084
+ "Setup & health \u2014 verify the local install and server.",
39085
+ state.serverUp === false ? " Run: `threadnote start` to bring OpenViking up." : " Run: `threadnote doctor` to check prerequisites.",
39086
+ "",
39087
+ "## How to proceed",
39088
+ "Pick the single most useful step for right now given the setup above (if the server is",
39089
+ "down, that first; otherwise a recall for the current repo is the usual starting point),",
39090
+ "describe it in one sentence, and ask whether to run it. Then chain into the others as the",
39091
+ "user shows interest. Keep it interactive \u2014 one offer at a time, not a wall of options."
39092
+ ].join("\n");
39093
+ }
39094
+
39095
+ // src/memory_hygiene.ts
39096
+ var import_node_path4 = require("node:path");
39097
+ var HYGIENE_SOURCES_HEADING = "## Threadnote Hygiene Sources";
39098
+ var STALE_HANDOFF_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
39099
+ function parseMemoryDocument(uri, content) {
39100
+ const trimmed = content.trim();
39101
+ if (!trimmed) {
39102
+ return void 0;
39103
+ }
39104
+ const separatorIndex = trimmed.indexOf("\n\n");
39105
+ const header = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
39106
+ const body = separatorIndex === -1 ? "" : trimmed.slice(separatorIndex + 2).trim();
39107
+ const firstLine = header.split("\n")[0]?.trim();
39108
+ if (firstLine !== "MEMORY" && firstLine !== "HANDOFF") {
39109
+ return void 0;
39110
+ }
39111
+ const kind = parseOptionalMemoryKind(headerValue(header, "kind")) ?? (firstLine === "HANDOFF" ? "handoff" : void 0);
39112
+ const status = parseOptionalMemoryStatus(headerValue(header, "status")) ?? "active";
39113
+ if (!kind) {
39114
+ return void 0;
39115
+ }
39116
+ return {
39117
+ body,
39118
+ content: trimmed,
39119
+ headerTitle: firstLine,
39120
+ metadata: {
39121
+ archivedFrom: headerValue(header, "archived_from"),
39122
+ kind,
39123
+ project: normalizeOptionalMetadata(headerValue(header, "project") ?? headerValue(header, "repo")),
39124
+ sourceAgentClient: headerValue(header, "source_agent_client") ?? "unknown",
39125
+ status,
39126
+ supersedes: headerValue(header, "supersedes"),
39127
+ timestamp: headerValue(header, "timestamp") ?? (/* @__PURE__ */ new Date(0)).toISOString(),
39128
+ topic: normalizeOptionalMetadata(headerValue(header, "topic"))
39129
+ },
39130
+ uri
39131
+ };
39132
+ }
39133
+ function buildCompactPlan(records, options) {
39134
+ const now = options.now ?? /* @__PURE__ */ new Date();
39135
+ const groupedRecords = records.map((record2) => groupableRecord(record2)).filter((item) => item !== void 0).filter((item) => item.project === options.project).filter((item) => options.topic === void 0 || item.topic === options.topic).filter((item) => options.kind === void 0 || item.record.metadata.kind === options.kind);
39136
+ const groups = /* @__PURE__ */ new Map();
39137
+ for (const item of groupedRecords) {
39138
+ groups.set(item.groupKey, [...groups.get(item.groupKey) ?? [], item]);
39139
+ }
39140
+ const keepUpdates = [];
39141
+ const archives = [];
39142
+ const forgets = [];
39143
+ const manualReview = [];
39144
+ for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
39145
+ const recordsInGroup = group.map((item) => item.record);
39146
+ const kind = recordsInGroup[0]?.metadata.kind;
39147
+ const topic = group[0]?.topic;
39148
+ const project = group[0]?.project;
39149
+ if (!kind || !project || !isCompactableKind(kind)) {
39150
+ continue;
39151
+ }
39152
+ const duplicateGroups = groupBy(recordsInGroup, (record2) => comparableMemoryBody(record2.body));
39153
+ const duplicateForgetUris = /* @__PURE__ */ new Set();
39154
+ const distinctBodyCount = duplicateGroups.size;
39155
+ for (const duplicateGroup of duplicateGroups.values()) {
39156
+ if (duplicateGroup.length < 2) {
39157
+ continue;
39158
+ }
39159
+ const duplicateKeep = preferredKeepRecord(duplicateGroup, topic);
39160
+ for (const duplicate of sortedNewestFirst(duplicateGroup).filter((record2) => record2.uri !== duplicateKeep.uri)) {
39161
+ duplicateForgetUris.add(duplicate.uri);
39162
+ forgets.push({ reason: `exact duplicate of ${duplicateKeep.uri}`, uri: duplicate.uri });
39163
+ }
39164
+ if (distinctBodyCount === 1 || kind !== "handoff") {
39165
+ keepUpdates.push({
39166
+ content: memoryContentWithHygieneSources(
39167
+ duplicateKeep,
39168
+ duplicateGroup.map((record2) => record2.uri)
39169
+ ),
39170
+ reason: "keep exact duplicate group with source URIs",
39171
+ sourceUris: duplicateGroup.map((record2) => record2.uri),
39172
+ uri: duplicateKeep.uri
39173
+ });
39174
+ }
39175
+ }
39176
+ const remainingRecords = recordsInGroup.filter((record2) => !duplicateForgetUris.has(record2.uri));
39177
+ if (recordsInGroup.length > 1 && distinctBodyCount === 1) {
39178
+ continue;
39179
+ }
39180
+ if (remainingRecords.length === 0) {
39181
+ continue;
39182
+ }
39183
+ if (remainingRecords.length === 1) {
39184
+ const [record2] = remainingRecords;
39185
+ if (!record2) {
39186
+ continue;
39187
+ }
39188
+ if (record2.metadata.supersedes === record2.uri) {
39189
+ keepUpdates.push({
39190
+ content: memoryContentWithHygieneSources(record2, [record2.uri]),
39191
+ reason: "strip self-supersedes header",
39192
+ sourceUris: [record2.uri],
39193
+ uri: record2.uri
39194
+ });
39195
+ }
39196
+ if (isStaleLookingHandoff(record2, now)) {
39197
+ manualReview.push({ reason: "stale-looking active handoff", uri: record2.uri });
39198
+ }
39199
+ continue;
39200
+ }
39201
+ if (kind === "handoff") {
39202
+ const keep = preferredKeepRecord(remainingRecords, topic);
39203
+ const sourceUris = recordsInGroup.map((record2) => record2.uri);
39204
+ keepUpdates.push({
39205
+ content: memoryContentWithHygieneSources(keep, sourceUris),
39206
+ reason: "keep latest handoff and preserve source URIs",
39207
+ sourceUris,
39208
+ uri: keep.uri
39209
+ });
39210
+ for (const record2 of sortedNewestFirst(remainingRecords).filter((item) => item.uri !== keep.uri)) {
39211
+ archives.push({
39212
+ kind,
39213
+ project,
39214
+ reason: `older handoff for ${project}/${topic ?? "unknown"}`,
39215
+ topic,
39216
+ uri: record2.uri
39217
+ });
39218
+ }
39219
+ continue;
39220
+ }
39221
+ for (const record2 of sortedNewestFirst(remainingRecords)) {
39222
+ manualReview.push({ reason: `non-exact ${kind} memory in overlapping group`, uri: record2.uri });
39223
+ }
39224
+ }
39225
+ return {
39226
+ archives: dedupeByUri(archives),
39227
+ forgets: dedupeByUri(forgets),
39228
+ keepUpdates: dedupeByUri(keepUpdates),
39229
+ manualReview: dedupeByUri(manualReview),
39230
+ options,
39231
+ recordsScanned: groupedRecords.length
39232
+ };
39233
+ }
39234
+ function memoryContentWithHygieneSources(record2, sourceUris) {
39235
+ const body = stripHygieneSources(record2.body);
39236
+ const uniqueSourceUris = [...new Set(sourceUris)].sort();
39237
+ const metadata = {
39238
+ ...record2.metadata,
39239
+ supersedes: record2.metadata.supersedes === record2.uri ? void 0 : record2.metadata.supersedes
39240
+ };
39241
+ return formatMemoryDocument(
39242
+ record2.headerTitle,
39243
+ metadata,
39244
+ [body, "", HYGIENE_SOURCES_HEADING, "", ...uniqueSourceUris.map((uri) => `- ${uri}`)].join("\n")
39245
+ );
39246
+ }
39247
+ function formatCompactPlan(plan, options) {
39248
+ const scope = [
39249
+ `project ${plan.options.project}`,
39250
+ plan.options.topic ? `topic ${plan.options.topic}` : void 0,
39251
+ plan.options.kind ? `kind ${plan.options.kind}` : void 0
39252
+ ].filter((item) => item !== void 0).join(", ");
39253
+ const lines = [
39254
+ `${options.apply ? "Applying" : "Dry-run"} memory hygiene plan for ${scope}`,
39255
+ `Records scanned: ${plan.recordsScanned}`,
39256
+ "",
39257
+ formatPlanSection(
39258
+ "Keep/update",
39259
+ plan.keepUpdates.map((action) => `${action.uri} (${action.reason}; sources: ${action.sourceUris.length})`)
39260
+ ),
39261
+ formatPlanSection(
39262
+ "Archive old handoffs",
39263
+ plan.archives.map((action) => `${action.uri} (${action.reason})`)
39264
+ ),
39265
+ formatPlanSection(
39266
+ "Forget exact duplicates",
39267
+ plan.forgets.map((action) => `${action.uri} (${action.reason})`)
39268
+ ),
39269
+ formatPlanSection(
39270
+ "Manual review",
39271
+ plan.manualReview.map((item) => `${item.uri} (${item.reason})`)
39272
+ )
39273
+ ];
39274
+ if (!options.apply) {
39275
+ lines.push("", "No changes made. Re-run with --apply to execute this plan.");
38004
39276
  }
38005
- return (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".claude", "commands", "threadnote", team, `${artifact.name}.md`);
39277
+ return lines.join("\n");
38006
39278
  }
38007
- function stripPersonalProvenance(content) {
38008
- const lines = content.split("\n");
38009
- let headerEnd = lines.length;
38010
- for (let index = 0; index < lines.length; index += 1) {
38011
- if (lines[index].trim() === "") {
38012
- headerEnd = index;
38013
- break;
39279
+ function recallHygieneNudges(text, options) {
39280
+ const activeUris = activePersonalMemoryUrisFromText(text, options.user);
39281
+ const nudges = [];
39282
+ const returnedUriSet = new Set(activeUris);
39283
+ const returnedRecords = options.records?.filter((record2) => returnedUriSet.has(record2.uri)).map((record2) => groupableRecord(record2)) ?? [];
39284
+ const groups = /* @__PURE__ */ new Map();
39285
+ for (const item of returnedRecords) {
39286
+ if (!item) {
39287
+ continue;
38014
39288
  }
39289
+ groups.set(item.groupKey, [...groups.get(item.groupKey) ?? [], item]);
38015
39290
  }
38016
- const cleaned = [];
38017
- for (let index = 0; index < headerEnd; index += 1) {
38018
- if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
39291
+ for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
39292
+ if (group.length < 3) {
38019
39293
  continue;
38020
39294
  }
38021
- cleaned.push(lines[index]);
38022
- }
38023
- for (let index = headerEnd; index < lines.length; index += 1) {
38024
- cleaned.push(lines[index]);
38025
- }
38026
- return cleaned.join("\n");
38027
- }
38028
- async function ensureSharedDirectoryChain(config2, ov, memoryUri, dryRun, options = {}) {
38029
- const directoryUri = parentUri(memoryUri);
38030
- for (const uri of sharedDirectoryChain(config2, directoryUri)) {
38031
- const args = withIdentity(config2, ["stat", uri]);
38032
- if (dryRun) {
38033
- if (options.quiet !== true) {
38034
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38035
- }
39295
+ const first = group[0];
39296
+ if (!first || !isCompactableKind(first.record.metadata.kind)) {
38036
39297
  continue;
38037
39298
  }
38038
- const statResult = await runCommand(ov, args, { allowFailure: true });
38039
- if (statResult.exitCode === 0) {
39299
+ const topic = first.topic;
39300
+ if (!topic) {
38040
39301
  continue;
38041
39302
  }
38042
- if (options.quiet === true) {
38043
- await runCommand(ov, withIdentity(config2, ["mkdir", uri, "--description", "Threadnote shared context."]));
38044
- } else {
38045
- await maybeRun(false, ov, withIdentity(config2, ["mkdir", uri, "--description", "Threadnote shared context."]));
39303
+ nudges.push(
39304
+ `${group.length} active ${memoryKindPlural(first.record.metadata.kind)} look overlapping for ${first.project}/${topic}; run compact_context({"project":"${first.project}","topic":"${topic}","dryRun":true}).`
39305
+ );
39306
+ }
39307
+ const projectCounts = /* @__PURE__ */ new Map();
39308
+ for (const uri of activeUris) {
39309
+ const parsed = parsePersonalMemoryUri(uri, options.user);
39310
+ if (!parsed || parsed.kind !== "handoff" || parsed.status !== "active") {
39311
+ continue;
39312
+ }
39313
+ projectCounts.set(parsed.project, (projectCounts.get(parsed.project) ?? 0) + 1);
39314
+ }
39315
+ for (const [project, count] of [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right))) {
39316
+ if (count < 10) {
39317
+ continue;
38046
39318
  }
39319
+ nudges.push(
39320
+ `Many active handoffs surfaced for ${project}; run compact_context({"project":"${project}","dryRun":true}).`
39321
+ );
38047
39322
  }
39323
+ return [...new Set(nudges)];
38048
39324
  }
38049
- function parentUri(uri) {
38050
- const lastSlash = uri.lastIndexOf("/");
38051
- return lastSlash === -1 ? uri : uri.slice(0, lastSlash);
39325
+ function activePersonalMemoryUrisFromText(text, user) {
39326
+ const userSegment = uriSegment(user);
39327
+ const matches = text.matchAll(/viking:\/\/[^\s)]+/g);
39328
+ const uris = [];
39329
+ for (const match of matches) {
39330
+ const uri = match[0]?.replace(/[.,;:]+$/g, "");
39331
+ if (!uri || !parsePersonalMemoryUri(uri, userSegment)) {
39332
+ continue;
39333
+ }
39334
+ uris.push(uri);
39335
+ }
39336
+ return [...new Set(uris)];
38052
39337
  }
38053
- function sharedDirectoryChain(config2, directoryUri) {
38054
- const prefix = `viking://user/${uriSegment(config2.user)}/memories/${SHARED_SEGMENT}/`;
38055
- if (!directoryUri.startsWith(prefix)) {
38056
- return [directoryUri];
39338
+ function parsePersonalMemoryUri(uri, user) {
39339
+ const prefix = `viking://user/${uriSegment(user)}/memories/`;
39340
+ if (!uri.startsWith(prefix) || uri.includes("/shared/")) {
39341
+ return void 0;
38057
39342
  }
38058
- const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
38059
- const chain = [];
38060
- for (let index = 1; index <= parts.length; index += 1) {
38061
- chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
39343
+ const rest = uri.slice(prefix.length);
39344
+ const parts = rest.split("/").filter(Boolean);
39345
+ if (parts.length < 4) {
39346
+ return void 0;
38062
39347
  }
38063
- return chain;
38064
- }
38065
- async function writeMemoryFile(config2, ov, uri, content, initialMode, dryRun, options = {}) {
38066
- if (dryRun) {
38067
- const args = withIdentity(config2, [
38068
- "write",
38069
- uri,
38070
- "--from-file",
38071
- "<staged temp file>",
38072
- "--mode",
38073
- initialMode,
38074
- "--wait",
38075
- "--timeout",
38076
- "120"
38077
- ]);
38078
- if (options.quiet !== true) {
38079
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38080
- }
38081
- return;
39348
+ if (parts[0] === "handoffs" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
39349
+ return { kind: "handoff", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
38082
39350
  }
38083
- const stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path4.join)((0, import_node_os2.tmpdir)(), "threadnote-share-"));
38084
- const tempPath = (0, import_node_path4.join)(stagingDir, "body.txt");
38085
- try {
38086
- await (0, import_promises4.writeFile)(tempPath, content, { encoding: "utf8", mode: 384 });
38087
- await writeOvFileWithRetry(config2, ov, uri, tempPath, initialMode, options);
38088
- await refreshMemoryIndex(config2, ov, uri, options);
38089
- } finally {
38090
- await (0, import_promises4.rm)(stagingDir, { force: true, recursive: true });
39351
+ if (parts[0] === "durable" && parts[1] === "projects" && parts[2] && parts[3]?.endsWith(".md")) {
39352
+ return { kind: "durable", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
39353
+ }
39354
+ if (parts[0] === "incidents" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
39355
+ return { kind: "incident", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
38091
39356
  }
39357
+ return void 0;
38092
39358
  }
38093
- var BUSY_RETRY_BACKOFF_MS = [2e3, 5e3, 1e4, 2e4, 3e4];
38094
- async function writeOvFileWithRetry(config2, ov, uri, fromFile, initialMode, options = {}) {
38095
- const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38096
- const existedBeforeWrite = await vikingResourceExists(ov, config2, uri);
38097
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38098
- const existsNow = attempt === 0 ? existedBeforeWrite : await vikingResourceExists(ov, config2, uri);
38099
- const ourWriteLanded = existsNow && !existedBeforeWrite;
38100
- const mode = attempt === 0 ? initialMode : ourWriteLanded ? "replace" : initialMode;
38101
- const args = withIdentity(config2, [
38102
- "write",
38103
- uri,
38104
- "--from-file",
38105
- fromFile,
38106
- "--mode",
38107
- mode,
38108
- "--wait",
38109
- "--timeout",
38110
- "120"
38111
- ]);
38112
- if (options.quiet !== true) {
38113
- console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
38114
- }
38115
- const result = await runCommand(ov, args, { allowFailure: true });
38116
- if (result.exitCode === 0) {
38117
- if (options.quiet !== true && result.stdout.trim()) {
38118
- console.log(result.stdout.trim());
38119
- }
38120
- if (options.quiet !== true && result.stderr.trim()) {
38121
- console.error(result.stderr.trim());
38122
- }
38123
- return;
38124
- }
38125
- if (isTransientOvFailure(result.stderr, result.stdout) && await vikingResourceExists(ov, config2, uri) && !existedBeforeWrite) {
38126
- if (options.quiet !== true) {
38127
- console.log(
38128
- "OpenViking accepted the write but returned an error before the wait completed; draining the queue."
38129
- );
38130
- }
38131
- await waitForOvQueue(ov, config2, options);
38132
- return;
38133
- }
38134
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38135
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38136
- }
38137
- if (isResourceBusyFailure(result.stderr, result.stdout)) {
38138
- await waitForOvQueue(ov, config2, options);
38139
- await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38140
- } else {
38141
- await sleep(1e3 * (attempt + 1));
38142
- }
39359
+ function groupableRecord(record2) {
39360
+ if (record2.metadata.status !== "active" || !isCompactableKind(record2.metadata.kind)) {
39361
+ return void 0;
38143
39362
  }
39363
+ const project = normalizeOptionalMetadata(record2.metadata.project) ?? parseProjectFromUri(record2.uri);
39364
+ if (!project) {
39365
+ return void 0;
39366
+ }
39367
+ const topic = topicForRecord(record2);
39368
+ const groupKey = [record2.metadata.kind, project, topic ?? record2.uri].join("\0");
39369
+ return { groupKey, project, record: record2, topic };
38144
39370
  }
38145
- async function refreshMemoryIndex(config2, ov, uri, options = {}) {
38146
- const result = await runCommand(
38147
- ov,
38148
- withIdentity(config2, ["reindex", uri, "--mode", "semantic_and_vectors", "--wait", "true"]),
38149
- { allowFailure: true }
38150
- );
38151
- if (result.exitCode === 0) {
38152
- if (options.quiet !== true && result.stdout.trim()) {
38153
- console.log(result.stdout.trim());
38154
- }
38155
- if (options.quiet !== true && result.stderr.trim()) {
38156
- console.error(result.stderr.trim());
38157
- }
38158
- return;
39371
+ function topicForRecord(record2) {
39372
+ return normalizeOptionalMetadata(record2.metadata.topic) ?? normalizeOptionalMetadata(branchFromBody(record2.body)) ?? topicFromUri(record2.uri);
39373
+ }
39374
+ function branchFromBody(body) {
39375
+ const branch = /^branch:\s*(.+)$/m.exec(body)?.[1]?.trim();
39376
+ return branch?.split(/\s+/)[0]?.replace(/[.,;:]+$/g, "");
39377
+ }
39378
+ function topicFromUri(uri) {
39379
+ const name = (0, import_node_path4.basename)(uri).replace(/\.md$/, "");
39380
+ return name.startsWith("threadnote-") ? void 0 : name;
39381
+ }
39382
+ function parseProjectFromUri(uri) {
39383
+ const parts = uri.split("/memories/")[1]?.split("/").filter(Boolean) ?? [];
39384
+ if ((parts[0] === "handoffs" || parts[0] === "incidents") && parts[1] === "active") {
39385
+ return parts[2];
38159
39386
  }
38160
- if (options.quiet !== true) {
38161
- console.error(
38162
- `Memory stored, but index refresh failed for ${uri}: ${result.stderr.trim() || result.stdout.trim()}`
38163
- );
39387
+ if (parts[0] === "durable" && parts[1] === "projects") {
39388
+ return parts[2];
38164
39389
  }
39390
+ return void 0;
38165
39391
  }
38166
- async function waitForOvQueue(ov, config2, options = {}) {
38167
- const result = await runCommand(ov, withIdentity(config2, ["wait", "--timeout", "120"]), { allowFailure: true });
38168
- if (options.quiet !== true && result.stdout.trim()) {
38169
- console.log(result.stdout.trim());
39392
+ function comparableMemoryBody(body) {
39393
+ return stripHygieneSources(body).trim().replace(/\s+/g, " ");
39394
+ }
39395
+ function stripHygieneSources(body) {
39396
+ const index = body.indexOf(`
39397
+ ${HYGIENE_SOURCES_HEADING}`);
39398
+ if (index !== -1) {
39399
+ return body.slice(0, index).trim();
39400
+ }
39401
+ return body.startsWith(HYGIENE_SOURCES_HEADING) ? "" : body.trim();
39402
+ }
39403
+ function preferredKeepRecord(records, topic) {
39404
+ const stableRecords = records.filter((record2) => isStableRecord(record2, topic));
39405
+ return sortedNewestFirst(stableRecords.length > 0 ? stableRecords : records)[0] ?? records[0];
39406
+ }
39407
+ function isStableRecord(record2, topic) {
39408
+ const recordTopic = topic ?? topicForRecord(record2);
39409
+ return recordTopic !== void 0 && (0, import_node_path4.basename)(record2.uri) === `${uriSegment(recordTopic)}.md`;
39410
+ }
39411
+ function sortedNewestFirst(records) {
39412
+ return [...records].sort((left, right) => {
39413
+ const timestampDiff = timestampMs(right) - timestampMs(left);
39414
+ return timestampDiff === 0 ? left.uri.localeCompare(right.uri) : timestampDiff;
39415
+ });
39416
+ }
39417
+ function timestampMs(record2) {
39418
+ const parsed = Date.parse(record2.metadata.timestamp);
39419
+ return Number.isFinite(parsed) ? parsed : 0;
39420
+ }
39421
+ function isStaleLookingHandoff(record2, now) {
39422
+ if (record2.metadata.kind !== "handoff") {
39423
+ return false;
38170
39424
  }
38171
- if (options.quiet !== true && result.stderr.trim()) {
38172
- console.error(result.stderr.trim());
39425
+ if (now.getTime() - timestampMs(record2) < STALE_HANDOFF_AGE_MS) {
39426
+ return false;
38173
39427
  }
39428
+ return /\b(?:PR|pull request)\s+(?:OPEN|open|is open)|awaiting review|waiting for review|next steps?:\s*address PR review/i.test(
39429
+ record2.body
39430
+ );
38174
39431
  }
38175
- function isTransientOvFailure(stderr, stdout2) {
38176
- const output = `${stderr}
38177
- ${stdout2}`.toLowerCase();
38178
- return output.includes("resource is busy") || output.includes("resource is being processed") || output.includes("network error") || output.includes("error sending request") || output.includes("http request failed") || output.includes("connection refused") || output.includes("connection reset") || output.includes("timed out");
38179
- }
38180
- function isResourceBusyFailure(stderr, stdout2) {
38181
- const output = `${stderr}
38182
- ${stdout2}`.toLowerCase();
38183
- return output.includes("resource is busy") || output.includes("resource is being processed");
39432
+ function groupBy(values, keyForValue) {
39433
+ const groups = /* @__PURE__ */ new Map();
39434
+ for (const value of values) {
39435
+ const key = keyForValue(value);
39436
+ groups.set(key, [...groups.get(key) ?? [], value]);
39437
+ }
39438
+ return groups;
38184
39439
  }
38185
- async function ingestSingleFile(ov, config2, uri, filePath, initialMode, options = {}) {
38186
- const content = await (0, import_promises4.readFile)(filePath, "utf8");
38187
- await writeMemoryFile(config2, ov, uri, content, initialMode, false, options);
39440
+ function compareGroupedRecordLists(left, right) {
39441
+ return (left[0]?.groupKey ?? "").localeCompare(right[0]?.groupKey ?? "");
38188
39442
  }
38189
- async function removeMemoryUri(config2, ov, uri, dryRun, options = {}) {
38190
- const args = withIdentity(config2, ["rm", uri]);
38191
- if (dryRun) {
38192
- if (options.quiet !== true) {
38193
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38194
- }
38195
- return;
38196
- }
38197
- const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38198
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38199
- const result = await runCommand(ov, args, { allowFailure: true });
38200
- if (result.exitCode === 0) {
38201
- if (options.quiet !== true && result.stdout.trim()) {
38202
- console.log(result.stdout.trim());
38203
- }
38204
- return;
38205
- }
38206
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38207
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38208
- }
38209
- if (isResourceBusyFailure(result.stderr, result.stdout)) {
38210
- await waitForOvQueue(ov, config2, options);
38211
- await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38212
- } else {
38213
- await sleep(1e3 * (attempt + 1));
39443
+ function dedupeByUri(items) {
39444
+ const seen = /* @__PURE__ */ new Set();
39445
+ const result = [];
39446
+ for (const item of items) {
39447
+ if (seen.has(item.uri)) {
39448
+ continue;
38214
39449
  }
39450
+ seen.add(item.uri);
39451
+ result.push(item);
38215
39452
  }
39453
+ return result;
38216
39454
  }
38217
- async function vikingResourceExists(ov, config2, uri) {
38218
- const result = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
38219
- return result.exitCode === 0;
39455
+ function formatMemoryDocument(title, metadata, body) {
39456
+ const header = [
39457
+ title,
39458
+ `kind: ${metadata.kind}`,
39459
+ `status: ${metadata.status}`,
39460
+ metadata.project ? `project: ${metadata.project}` : void 0,
39461
+ metadata.topic ? `topic: ${metadata.topic}` : void 0,
39462
+ `source_agent_client: ${metadata.sourceAgentClient}`,
39463
+ `timestamp: ${metadata.timestamp}`,
39464
+ metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
39465
+ metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
39466
+ ].filter((line) => line !== void 0);
39467
+ return [...header, "", body.trim()].join("\n");
38220
39468
  }
38221
- async function hasUncommittedChanges(worktree) {
38222
- const result = await runCommand("git", ["-C", worktree, "status", "--porcelain"], { allowFailure: true });
38223
- return result.stdout.trim().length > 0;
39469
+ function headerValue(header, key) {
39470
+ const prefix = `${key}:`;
39471
+ return header.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim();
38224
39472
  }
38225
- async function gitOutput(worktree, args, dryRun) {
38226
- if (dryRun) {
39473
+ function parseOptionalMemoryKind(value) {
39474
+ if (!value) {
38227
39475
  return void 0;
38228
39476
  }
38229
- const result = await runCommand("git", ["-C", worktree, ...args], { allowFailure: true });
38230
- if (result.exitCode !== 0) {
38231
- return void 0;
39477
+ if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
39478
+ return value;
38232
39479
  }
38233
- return result.stdout.trim();
39480
+ return void 0;
38234
39481
  }
38235
- async function listChangedFiles(worktree, beforeRev, afterRev) {
38236
- const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
38237
- allowFailure: true
38238
- });
38239
- if (result.exitCode !== 0) {
38240
- return [];
39482
+ function parseOptionalMemoryStatus(value) {
39483
+ if (!value) {
39484
+ return void 0;
38241
39485
  }
38242
- const entries = result.stdout.split("\0").filter((part) => part.length > 0);
38243
- const changes = [];
38244
- for (let index = 0; index < entries.length; ) {
38245
- const raw = entries[index];
38246
- const head = raw.slice(0, 1);
38247
- if (head === "R" || head === "C") {
38248
- const oldRel = entries[index + 1];
38249
- const newRel = entries[index + 2];
38250
- if (oldRel && newRel) {
38251
- changes.push({ path: (0, import_node_path4.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
38252
- changes.push({ path: (0, import_node_path4.join)(worktree, newRel), relativePath: newRel, status: "added" });
38253
- }
38254
- index += 3;
38255
- continue;
38256
- }
38257
- const rel = entries[index + 1];
38258
- if (rel) {
38259
- const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
38260
- changes.push({ path: (0, import_node_path4.join)(worktree, rel), relativePath: rel, status });
38261
- }
38262
- index += 2;
39486
+ if (["active", "archived", "superseded"].includes(value)) {
39487
+ return value;
38263
39488
  }
38264
- return changes;
39489
+ return void 0;
38265
39490
  }
38266
- async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38267
- const ov = await openVikingCliForMode(false);
38268
- const failed = [];
38269
- for (const change of changes) {
38270
- if (!change.relativePath.endsWith(".md")) {
38271
- continue;
38272
- }
38273
- const firstSegment = change.relativePath.split("/")[0];
38274
- if (!SHAREABLE_TOP_LEVEL_DIRS.includes(firstSegment)) {
38275
- continue;
38276
- }
38277
- const uri = workfileToVikingUri(config2, team, change.path);
38278
- try {
38279
- if (change.status === "removed") {
38280
- await removeMemoryUri(config2, ov, uri, false, options);
38281
- continue;
38282
- }
38283
- if (!await isFile(change.path)) {
38284
- continue;
38285
- }
38286
- const ovHasResource = await vikingResourceExists(ov, config2, uri);
38287
- if (ovHasResource) {
38288
- const reason = change.status === "modified" ? "overwriting local with upstream (local edits to the shared subtree are not preserved across sync)" : "aligning OV to upstream (resource pre-existed in OV, likely from an earlier local publish or sync)";
38289
- if (options.quiet !== true) {
38290
- console.warn(`share sync: ${uri}: ${reason}.`);
38291
- }
38292
- }
38293
- await ensureSharedDirectoryChain(config2, ov, uri, false, options);
38294
- const writeMode = ovHasResource ? "replace" : "create";
38295
- await ingestSingleFile(ov, config2, uri, change.path, writeMode, options);
38296
- } catch (err) {
38297
- const message = err instanceof Error ? err.message : String(err);
38298
- if (options.quiet !== true) {
38299
- console.warn(`share sync: ${uri}: ingest failed \u2014 will retry on the next sync. ${message}`);
38300
- }
38301
- failed.push(change);
38302
- }
38303
- }
38304
- return { failed };
39491
+ function normalizeOptionalMetadata(value) {
39492
+ const trimmed = value?.trim();
39493
+ return trimmed ? trimmed : void 0;
38305
39494
  }
38306
- function mergeChanges(...lists) {
38307
- const map3 = /* @__PURE__ */ new Map();
38308
- for (const list of lists) {
38309
- for (const change of list) {
38310
- map3.set(change.relativePath, change);
38311
- }
38312
- }
38313
- return [...map3.values()];
39495
+ function isCompactableKind(kind) {
39496
+ return kind === "durable" || kind === "handoff" || kind === "incident";
38314
39497
  }
38315
- async function applyAndPersistChanges(config2, team, state, changes, options = {}) {
38316
- if (changes.length === 0) {
38317
- return { failed: [] };
39498
+ function memoryKindPlural(kind) {
39499
+ switch (kind) {
39500
+ case "handoff":
39501
+ return "handoffs";
39502
+ case "incident":
39503
+ return "incidents";
39504
+ case "durable":
39505
+ return "durable memories";
39506
+ case "preference":
39507
+ return "preferences";
39508
+ case "smoke":
39509
+ return "smoke memories";
38318
39510
  }
38319
- state.pendingReindexes.set(team.name, changes);
38320
- await writePendingReindexes(config2, state);
38321
- const result = await applyChangesToOpenViking(config2, team, changes, options);
38322
- if (result.failed.length > 0) {
38323
- state.pendingReindexes.set(team.name, result.failed);
38324
- } else {
38325
- state.pendingReindexes.delete(team.name);
39511
+ }
39512
+ function formatPlanSection(title, lines) {
39513
+ if (lines.length === 0) {
39514
+ return `${title}:
39515
+ - none`;
38326
39516
  }
38327
- await writePendingReindexes(config2, state);
38328
- return result;
39517
+ return [`${title}:`, ...lines.map((line) => `- ${line}`)].join("\n");
38329
39518
  }
38330
39519
 
38331
39520
  // src/mcp_server.ts
@@ -38408,6 +39597,15 @@ function registerTools(server, config2) {
38408
39597
  );
38409
39598
  registerArchiveTool(server, config2, "archive", "Compatibility alias for archive_context.");
38410
39599
  registerCompactTool(server, config2);
39600
+ server.registerTool(
39601
+ "threadnote_guide",
39602
+ {
39603
+ annotations: { readOnlyHint: true, destructiveHint: false },
39604
+ description: "Onboard the user to Threadnote. Call this when the user asks what they can do with Threadnote, how to get started, for a feature tour/overview, or how Threadnote works \u2014 anything exploratory about Threadnote's capabilities. Returns a state-aware walkthrough (tailored to the user's server health, configured share teams, and seeded projects) written as guidance for YOU to present conversationally and offer to run step by step, not a message to paste. Prefer calling this over describing Threadnote from memory, so the guidance matches the user's actual setup. Takes no arguments.",
39605
+ inputSchema: {}
39606
+ },
39607
+ async () => runThreadnoteGuideTool(config2)
39608
+ );
38411
39609
  server.registerTool(
38412
39610
  "forget",
38413
39611
  {
@@ -38457,14 +39655,14 @@ function registerTools(server, config2) {
38457
39655
  "grep",
38458
39656
  {
38459
39657
  annotations: { readOnlyHint: true, destructiveHint: false },
38460
- description: "Run exact text search in OpenViking.",
39658
+ description: "Run exact text search in OpenViking. Defaults to your memories subtree when uri is omitted (OpenViking grep requires a scope).",
38461
39659
  inputSchema: {
38462
39660
  caseInsensitive: external_exports.boolean().optional().describe("Case-insensitive search"),
38463
39661
  case_insensitive: external_exports.boolean().optional().describe("Case-insensitive search"),
38464
39662
  nodeLimit: external_exports.number().int().positive().max(1e3).optional().describe("Maximum result count"),
38465
39663
  node_limit: external_exports.number().int().positive().max(1e3).optional().describe("Maximum result count"),
38466
39664
  pattern: external_exports.string().optional().describe("Required text or regex pattern"),
38467
- uri: external_exports.string().optional().describe("Optional viking:// subtree")
39665
+ uri: external_exports.string().optional().describe("Optional viking:// subtree (defaults to your memories root)")
38468
39666
  }
38469
39667
  },
38470
39668
  async ({ caseInsensitive, case_insensitive, nodeLimit, node_limit, pattern, uri }) => {
@@ -38480,7 +39678,7 @@ function registerTools(server, config2) {
38480
39678
  case_insensitive: caseInsensitive ?? case_insensitive,
38481
39679
  node_limit: nodeLimit ?? node_limit,
38482
39680
  pattern: checkedPattern.value,
38483
- uri: checkedUri.value
39681
+ uri: checkedUri.value ?? `viking://user/${uriSegment2(config2.user)}/memories`
38484
39682
  });
38485
39683
  }
38486
39684
  );
@@ -38554,9 +39752,10 @@ function registerTools(server, config2) {
38554
39752
  "share_skill",
38555
39753
  {
38556
39754
  annotations: { readOnlyHint: false, destructiveHint: true },
38557
- description: "Publish a local Codex/Claude skill or Claude command markdown file into a team's shared artifact catalog. Path inference handles ~/.codex/skills/**/SKILL.md, ~/.claude/skills/**/SKILL.md, and ~/.claude/commands/**/*.md; pass agent/kind/name when sharing from another path. Default team is used unless team is provided. Pass preview=true to inspect bytes without writing or committing.",
39755
+ description: "Publish a local Codex/Claude skill or Claude command markdown file into a team's shared artifact catalog. Path inference handles ~/.codex/skills/**/SKILL.md, ~/.claude/skills/**/SKILL.md, and ~/.claude/commands/**/*.md; pass agent/kind/name when sharing from another path. A skill is shared as its whole directory: companion files (scripts, references, assets) beside the SKILL.md travel with it. Default team is used unless team is provided. Pass preview=true to inspect what would land without writing or committing.",
38558
39756
  inputSchema: {
38559
39757
  agent: external_exports.enum(["codex", "claude"]).optional().describe("Agent owner when path inference is ambiguous"),
39758
+ allowBinary: external_exports.boolean().optional().describe("Include binary skill files (unscannable by the scrubber); blocked by default"),
38560
39759
  force: external_exports.boolean().optional().describe("Replace an existing shared artifact with different content"),
38561
39760
  kind: external_exports.enum(["skill", "command"]).optional().describe("Artifact kind when path inference is ambiguous"),
38562
39761
  message: external_exports.string().optional().describe('Commit message override; defaults to "share: publish <path>"'),
@@ -38568,7 +39767,7 @@ function registerTools(server, config2) {
38568
39767
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38569
39768
  }
38570
39769
  },
38571
- async ({ agent, force, kind, message, name, path, preview, push, redact, team }) => {
39770
+ async ({ agent, allowBinary, force, kind, message, name, path, preview, push, redact, team }) => {
38572
39771
  const checkedPath = requiredText(path, "share_skill", "path", {
38573
39772
  path: "~/.codex/skills/example/SKILL.md"
38574
39773
  });
@@ -38577,6 +39776,7 @@ function registerTools(server, config2) {
38577
39776
  }
38578
39777
  return runShareSkillTool(config2, checkedPath.value, {
38579
39778
  agent,
39779
+ allowBinary,
38580
39780
  force,
38581
39781
  kind,
38582
39782
  message,
@@ -38588,14 +39788,40 @@ function registerTools(server, config2) {
38588
39788
  });
38589
39789
  }
38590
39790
  );
39791
+ server.registerTool(
39792
+ "share_bundle",
39793
+ {
39794
+ annotations: { readOnlyHint: false, destructiveHint: true },
39795
+ description: "Publish a multi-skill constellation (pack) into a team's shared artifact catalog from a threadnote-bundle.json manifest. Use this when several skills share code that lives outside any single skill directory (e.g. repo-root scripts/lib). The manifest declares name, agent, skills, include paths, external deps, and pathRewrites. Hardcoded repo-root paths are rewritten to a portable token and expanded on install. Pass preview=true to inspect what would land without writing or committing.",
39796
+ inputSchema: {
39797
+ allowBinary: external_exports.boolean().optional().describe("Include binary files (unscannable by the scrubber); blocked by default"),
39798
+ force: external_exports.boolean().optional().describe("Replace existing shared pack files with different content"),
39799
+ message: external_exports.string().optional().describe("Commit message override"),
39800
+ path: external_exports.string().optional().describe("Required local path to a threadnote-bundle.json manifest"),
39801
+ preview: external_exports.boolean().optional().describe("Return what would land in the shared git repo without writing"),
39802
+ push: external_exports.boolean().optional().describe("Push to remote after committing; defaults to true"),
39803
+ redact: external_exports.boolean().optional().describe("Replace soft-leak matches (local paths) with placeholders and continue; credentials still block."),
39804
+ team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
39805
+ }
39806
+ },
39807
+ async ({ allowBinary, force, message, path, preview, push, redact, team }) => {
39808
+ const checkedPath = requiredText(path, "share_bundle", "path", {
39809
+ path: "~/src/reviewer/threadnote-bundle.json"
39810
+ });
39811
+ if (!checkedPath.ok) {
39812
+ return checkedPath.error;
39813
+ }
39814
+ return runShareBundleTool(config2, checkedPath.value, { allowBinary, force, message, preview, push, redact, team });
39815
+ }
39816
+ );
38591
39817
  server.registerTool(
38592
39818
  "list_shared_skills",
38593
39819
  {
38594
39820
  annotations: { readOnlyHint: true, destructiveHint: false },
38595
- description: "List shared Codex/Claude skills and Claude commands available in a configured Threadnote team repo, including whether each one is already installed locally.",
39821
+ description: "List shared Codex/Claude skills, Claude commands, and skill packs available in a configured Threadnote team repo, including whether each one is already installed locally.",
38596
39822
  inputSchema: {
38597
39823
  agent: external_exports.enum(["codex", "claude"]).optional().describe("Optional agent filter"),
38598
- kind: external_exports.enum(["skill", "command"]).optional().describe("Optional kind filter"),
39824
+ kind: external_exports.enum(["skill", "command", "pack"]).optional().describe("Optional kind filter"),
38599
39825
  name: external_exports.string().optional().describe("Optional shared artifact name filter"),
38600
39826
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38601
39827
  }
@@ -38611,7 +39837,7 @@ function registerTools(server, config2) {
38611
39837
  agent: external_exports.enum(["codex", "claude"]).optional().describe("Agent owner; required when name is ambiguous"),
38612
39838
  dryRun: external_exports.boolean().optional().describe("Preview install without writing local files"),
38613
39839
  force: external_exports.boolean().optional().describe("Replace an existing installed artifact with different content"),
38614
- kind: external_exports.enum(["skill", "command"]).optional().describe("Artifact kind; required when name is ambiguous"),
39840
+ kind: external_exports.enum(["skill", "command", "pack"]).optional().describe("Artifact kind; required when name is ambiguous"),
38615
39841
  name: external_exports.string().optional().describe("Required shared artifact name to install"),
38616
39842
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38617
39843
  }
@@ -39045,7 +40271,8 @@ async function runRecallTool(config2, params) {
39045
40271
  passes.push(seeded.hits);
39046
40272
  }
39047
40273
  const sections = [];
39048
- const semanticSection = formatRecallHits(mergeRecallHits(passes), params.nodeLimit ?? 12);
40274
+ const exactMatches = await collectExactMemoryMatches(config2, query, params.includeArchived, project);
40275
+ const { semanticSection, exactTail } = buildRecallSections(passes, exactMatches, params.nodeLimit ?? 12);
39049
40276
  if (semanticSection) {
39050
40277
  sections.push(semanticSection);
39051
40278
  } else if (!base.ok) {
@@ -39054,9 +40281,8 @@ async function runRecallTool(config2, params) {
39054
40281
  if (indexRepairMessages.length > 0) {
39055
40282
  sections.push(indexRepairMessages.join("\n"));
39056
40283
  }
39057
- const exactMatches = await exactMemoryMatchesText(config2, query, params.includeArchived, project);
39058
- if (exactMatches) {
39059
- sections.push(exactMatches);
40284
+ if (exactTail) {
40285
+ sections.push(exactTail);
39060
40286
  }
39061
40287
  const hygieneHints = await recallHygieneHintsSection(config2, sections.join("\n\n"));
39062
40288
  if (hygieneHints) {
@@ -39103,14 +40329,14 @@ async function recallHygieneHintsSection(config2, recallText) {
39103
40329
  const nudges = recallHygieneNudges(recallText, { records, user: config2.user });
39104
40330
  return nudges.length > 0 ? ["Memory hygiene hints:", ...nudges.map((nudge) => `- ${nudge}`)].join("\n") : void 0;
39105
40331
  }
39106
- async function exactMemoryMatchesText(config2, query, includeArchived, project) {
40332
+ async function collectExactMemoryMatches(config2, query, includeArchived, project) {
39107
40333
  const terms = exactRecallTerms(query);
39108
40334
  if (terms.length === 0) {
39109
- return void 0;
40335
+ return [];
39110
40336
  }
39111
40337
  const ov = await requiredOpenVikingCli2();
39112
40338
  const scopes = exactMemoryScopes(config2, includeArchived, query, project);
39113
- const matches = await collectExactMatches(terms, scopes, async (term, scope) => {
40339
+ return collectExactMatches(terms, scopes, async (term, scope) => {
39114
40340
  const result = await runCommand(
39115
40341
  ov,
39116
40342
  withIdentity2(config2, ["grep", term, "--uri", scope, "--node-limit", "5", "--output", "json"]),
@@ -39118,7 +40344,6 @@ async function exactMemoryMatchesText(config2, query, includeArchived, project)
39118
40344
  );
39119
40345
  return result.exitCode === 0 ? result.stdout : void 0;
39120
40346
  });
39121
- return formatExactMatchPointers(matches);
39122
40347
  }
39123
40348
  function registerReadTool(server, config2, name, description) {
39124
40349
  server.registerTool(
@@ -39998,6 +41223,34 @@ async function runShareSkillTool(config2, sourcePath, options) {
39998
41223
  return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
39999
41224
  }
40000
41225
  }
41226
+ async function runShareBundleTool(config2, manifestPath, options) {
41227
+ try {
41228
+ const result = await shareBundlePack(config2, manifestPath, options);
41229
+ const lines = [...result.messages, ...result.gitMessages];
41230
+ if (result.previewContent !== void 0) {
41231
+ lines.push("-----BEGIN PREVIEW-----");
41232
+ lines.push(result.previewContent);
41233
+ lines.push("-----END PREVIEW-----");
41234
+ }
41235
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
41236
+ } catch (err) {
41237
+ return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
41238
+ }
41239
+ }
41240
+ async function runThreadnoteGuideTool(config2) {
41241
+ const serverUp = await probeServerUp(config2);
41242
+ const context = await gatherOnboardingContext(config2);
41243
+ const text = buildOnboardingGuide({ ...context, serverUp });
41244
+ return { content: [{ type: "text", text }], isError: false };
41245
+ }
41246
+ async function probeServerUp(config2) {
41247
+ try {
41248
+ const result = await runOpenVikingMcpTool(config2, "health", {});
41249
+ return result.isError !== true;
41250
+ } catch (_err) {
41251
+ return false;
41252
+ }
41253
+ }
40001
41254
  async function runListSharedSkillsTool(config2, options) {
40002
41255
  try {
40003
41256
  const result = await listSharedAgentArtifacts(config2, options);