threadnote 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36820,835 +36820,1126 @@ function indexRepairWarning(uri, result) {
36820
36820
  return `${uri}: ${result.stderr.trim() || result.stdout.trim() || "reindex failed"}`;
36821
36821
  }
36822
36822
 
36823
- // src/memory_hygiene.ts
36823
+ // src/share.ts
36824
+ var import_promises4 = require("node:fs/promises");
36825
+ var import_node_os2 = require("node:os");
36824
36826
  var import_node_path3 = require("node:path");
36825
- var HYGIENE_SOURCES_HEADING = "## Threadnote Hygiene Sources";
36826
- var STALE_HANDOFF_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
36827
- function parseMemoryDocument(uri, content) {
36828
- const trimmed = content.trim();
36829
- if (!trimmed) {
36830
- return void 0;
36831
- }
36832
- const separatorIndex = trimmed.indexOf("\n\n");
36833
- const header = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex);
36834
- const body = separatorIndex === -1 ? "" : trimmed.slice(separatorIndex + 2).trim();
36835
- const firstLine = header.split("\n")[0]?.trim();
36836
- if (firstLine !== "MEMORY" && firstLine !== "HANDOFF") {
36837
- return void 0;
36838
- }
36839
- const kind = parseOptionalMemoryKind(headerValue(header, "kind")) ?? (firstLine === "HANDOFF" ? "handoff" : void 0);
36840
- const status = parseOptionalMemoryStatus(headerValue(header, "status")) ?? "active";
36841
- if (!kind) {
36842
- 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 });
36843
36889
  }
36844
- return {
36845
- body,
36846
- content: trimmed,
36847
- headerTitle: firstLine,
36848
- metadata: {
36849
- archivedFrom: headerValue(header, "archived_from"),
36850
- kind,
36851
- project: normalizeOptionalMetadata(headerValue(header, "project") ?? headerValue(header, "repo")),
36852
- sourceAgentClient: headerValue(header, "source_agent_client") ?? "unknown",
36853
- status,
36854
- supersedes: headerValue(header, "supersedes"),
36855
- timestamp: headerValue(header, "timestamp") ?? (/* @__PURE__ */ new Date(0)).toISOString(),
36856
- topic: normalizeOptionalMetadata(headerValue(header, "topic"))
36857
- },
36858
- uri
36859
- };
36890
+ return { cleaned, redactions };
36860
36891
  }
36861
- function buildCompactPlan(records, options) {
36862
- const now = options.now ?? /* @__PURE__ */ new Date();
36863
- 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);
36864
- const groups = /* @__PURE__ */ new Map();
36865
- for (const item of groupedRecords) {
36866
- 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;
36867
36897
  }
36868
- const keepUpdates = [];
36869
- const archives = [];
36870
- const forgets = [];
36871
- const manualReview = [];
36872
- for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
36873
- const recordsInGroup = group.map((item) => item.record);
36874
- const kind = recordsInGroup[0]?.metadata.kind;
36875
- const topic = group[0]?.topic;
36876
- const project = group[0]?.project;
36877
- if (!kind || !project || !isCompactableKind(kind)) {
36878
- 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 };
36879
36916
  }
36880
- const duplicateGroups = groupBy(recordsInGroup, (record2) => comparableMemoryBody(record2.body));
36881
- const duplicateForgetUris = /* @__PURE__ */ new Set();
36882
- const distinctBodyCount = duplicateGroups.size;
36883
- for (const duplicateGroup of duplicateGroups.values()) {
36884
- if (duplicateGroup.length < 2) {
36885
- continue;
36886
- }
36887
- const duplicateKeep = preferredKeepRecord(duplicateGroup, topic);
36888
- for (const duplicate of sortedNewestFirst(duplicateGroup).filter((record2) => record2.uri !== duplicateKeep.uri)) {
36889
- duplicateForgetUris.add(duplicate.uri);
36890
- forgets.push({ reason: `exact duplicate of ${duplicateKeep.uri}`, uri: duplicate.uri });
36891
- }
36892
- if (distinctBodyCount === 1 || kind !== "handoff") {
36893
- keepUpdates.push({
36894
- content: memoryContentWithHygieneSources(
36895
- duplicateKeep,
36896
- duplicateGroup.map((record2) => record2.uri)
36897
- ),
36898
- reason: "keep exact duplicate group with source URIs",
36899
- sourceUris: duplicateGroup.map((record2) => record2.uri),
36900
- uri: duplicateKeep.uri
36901
- });
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
+ );
36902
36932
  }
36903
36933
  }
36904
- const remainingRecords = recordsInGroup.filter((record2) => !duplicateForgetUris.has(record2.uri));
36905
- if (recordsInGroup.length > 1 && distinctBodyCount === 1) {
36906
- continue;
36907
- }
36908
- 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)) {
36909
36965
  continue;
36910
36966
  }
36911
- if (remainingRecords.length === 1) {
36912
- const [record2] = remainingRecords;
36913
- if (!record2) {
36967
+ const changes = [];
36968
+ for (const item of value) {
36969
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
36914
36970
  continue;
36915
36971
  }
36916
- if (record2.metadata.supersedes === record2.uri) {
36917
- keepUpdates.push({
36918
- content: memoryContentWithHygieneSources(record2, [record2.uri]),
36919
- reason: "strip self-supersedes header",
36920
- sourceUris: [record2.uri],
36921
- uri: record2.uri
36922
- });
36923
- }
36924
- if (isStaleLookingHandoff(record2, now)) {
36925
- manualReview.push({ reason: "stale-looking active handoff", uri: record2.uri });
36926
- }
36927
- continue;
36928
- }
36929
- if (kind === "handoff") {
36930
- const keep = preferredKeepRecord(remainingRecords, topic);
36931
- const sourceUris = recordsInGroup.map((record2) => record2.uri);
36932
- keepUpdates.push({
36933
- content: memoryContentWithHygieneSources(keep, sourceUris),
36934
- reason: "keep latest handoff and preserve source URIs",
36935
- sourceUris,
36936
- uri: keep.uri
36937
- });
36938
- for (const record2 of sortedNewestFirst(remainingRecords).filter((item) => item.uri !== keep.uri)) {
36939
- archives.push({
36940
- kind,
36941
- project,
36942
- reason: `older handoff for ${project}/${topic ?? "unknown"}`,
36943
- topic,
36944
- uri: record2.uri
36945
- });
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 });
36946
36975
  }
36947
- continue;
36948
36976
  }
36949
- for (const record2 of sortedNewestFirst(remainingRecords)) {
36950
- manualReview.push({ reason: `non-exact ${kind} memory in overlapping group`, uri: record2.uri });
36977
+ if (changes.length > 0) {
36978
+ pending.set(team, changes);
36951
36979
  }
36952
36980
  }
36953
- return {
36954
- archives: dedupeByUri(archives),
36955
- forgets: dedupeByUri(forgets),
36956
- keepUpdates: dedupeByUri(keepUpdates),
36957
- manualReview: dedupeByUri(manualReview),
36958
- options,
36959
- recordsScanned: groupedRecords.length
36960
- };
36981
+ state.pendingReindexes = pending;
36961
36982
  }
36962
- function memoryContentWithHygieneSources(record2, sourceUris) {
36963
- const body = stripHygieneSources(record2.body);
36964
- const uniqueSourceUris = [...new Set(sourceUris)].sort();
36965
- const metadata = {
36966
- ...record2.metadata,
36967
- 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
36968
36992
  };
36969
- return formatMemoryDocument(
36970
- record2.headerTitle,
36971
- metadata,
36972
- [body, "", HYGIENE_SOURCES_HEADING, "", ...uniqueSourceUris.map((uri) => `- ${uri}`)].join("\n")
36973
- );
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);
36974
36998
  }
36975
- function formatCompactPlan(plan, options) {
36976
- const scope = [
36977
- `project ${plan.options.project}`,
36978
- plan.options.topic ? `topic ${plan.options.topic}` : void 0,
36979
- plan.options.kind ? `kind ${plan.options.kind}` : void 0
36980
- ].filter((item) => item !== void 0).join(", ");
36981
- const lines = [
36982
- `${options.apply ? "Applying" : "Dry-run"} memory hygiene plan for ${scope}`,
36983
- `Records scanned: ${plan.recordsScanned}`,
36984
- "",
36985
- formatPlanSection(
36986
- "Keep/update",
36987
- plan.keepUpdates.map((action) => `${action.uri} (${action.reason}; sources: ${action.sourceUris.length})`)
36988
- ),
36989
- formatPlanSection(
36990
- "Archive old handoffs",
36991
- plan.archives.map((action) => `${action.uri} (${action.reason})`)
36992
- ),
36993
- formatPlanSection(
36994
- "Forget exact duplicates",
36995
- plan.forgets.map((action) => `${action.uri} (${action.reason})`)
36996
- ),
36997
- formatPlanSection(
36998
- "Manual review",
36999
- plan.manualReview.map((item) => `${item.uri} (${item.reason})`)
37000
- )
37001
- ];
37002
- if (!options.apply) {
37003
- 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);
37004
37007
  }
37005
- return lines.join("\n");
37006
37008
  }
37007
- function recallHygieneNudges(text, options) {
37008
- const activeUris = activePersonalMemoryUrisFromText(text, options.user);
37009
- const nudges = [];
37010
- const returnedUriSet = new Set(activeUris);
37011
- const returnedRecords = options.records?.filter((record2) => returnedUriSet.has(record2.uri)).map((record2) => groupableRecord(record2)) ?? [];
37012
- const groups = /* @__PURE__ */ new Map();
37013
- for (const item of returnedRecords) {
37014
- if (!item) {
37015
- continue;
37016
- }
37017
- 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 [];
37018
37013
  }
37019
- for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
37020
- if (group.length < 3) {
37021
- 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
+ }
37022
37026
  }
37023
- const first = group[0];
37024
- if (!first || !isCompactableKind(first.record.metadata.kind)) {
37027
+ state.behindTeams = nextBehindTeams;
37028
+ return statuses.flatMap((status) => status.warning ? [status.warning] : []);
37029
+ } finally {
37030
+ state.lastCheckedAt = Date.now();
37031
+ }
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 [];
37044
+ }
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
+ });
37025
37057
  continue;
37026
37058
  }
37027
- const topic = first.topic;
37028
- if (!topic) {
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
+ });
37029
37066
  continue;
37030
37067
  }
37031
- nudges.push(
37032
- `${group.length} active ${memoryKindPlural(first.record.metadata.kind)} look overlapping for ${first.project}/${topic}; run compact_context({"project":"${first.project}","topic":"${topic}","dryRun":true}).`
37033
- );
37068
+ statuses.push({ behind: Number.parseInt(behind, 10) || 0, team: name });
37034
37069
  }
37035
- const projectCounts = /* @__PURE__ */ new Map();
37036
- for (const uri of activeUris) {
37037
- const parsed = parsePersonalMemoryUri(uri, options.user);
37038
- if (!parsed || parsed.kind !== "handoff" || parsed.status !== "active") {
37039
- continue;
37040
- }
37041
- projectCounts.set(parsed.project, (projectCounts.get(parsed.project) ?? 0) + 1);
37070
+ return statuses;
37071
+ }
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 });
37042
37079
  }
37043
- for (const [project, count] of [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right))) {
37044
- if (count < 10) {
37045
- continue;
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.`;
37082
+ }
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.`;
37086
+ }
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.`;
37089
+ }
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
+ );
37046
37097
  }
37047
- nudges.push(
37048
- `Many active handoffs surfaced for ${project}; run compact_context({"project":"${project}","dryRun":true}).`
37098
+ throw new Error(
37099
+ `Automatic share sync failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
37049
37100
  );
37050
37101
  }
37051
- return [...new Set(nudges)];
37052
- }
37053
- function activePersonalMemoryUrisFromText(text, user) {
37054
- const userSegment = uriSegment(user);
37055
- const matches = text.matchAll(/viking:\/\/[^\s)]+/g);
37056
- const uris = [];
37057
- for (const match of matches) {
37058
- const uri = match[0]?.replace(/[.,;:]+$/g, "");
37059
- if (!uri || !parsePersonalMemoryUri(uri, userSegment)) {
37060
- continue;
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 });
37061
37109
  }
37062
- uris.push(uri);
37063
37110
  }
37064
- return [...new Set(uris)];
37111
+ return void 0;
37065
37112
  }
37066
- function parsePersonalMemoryUri(uri, user) {
37067
- const prefix = `viking://user/${uriSegment(user)}/memories/`;
37068
- if (!uri.startsWith(prefix) || uri.includes("/shared/")) {
37069
- return void 0;
37070
- }
37071
- const rest = uri.slice(prefix.length);
37072
- const parts = rest.split("/").filter(Boolean);
37073
- if (parts.length < 4) {
37074
- 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"}`);
37075
37124
  }
37076
- if (parts[0] === "handoffs" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
37077
- return { kind: "handoff", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
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
+ }
37078
37139
  }
37079
- if (parts[0] === "durable" && parts[1] === "projects" && parts[2] && parts[3]?.endsWith(".md")) {
37080
- return { kind: "durable", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
37140
+ if (!push) {
37141
+ messages.push("git push skipped (push=false)");
37142
+ return messages;
37081
37143
  }
37082
- if (parts[0] === "incidents" && parts[1] === "active" && parts[2] && parts[3]?.endsWith(".md")) {
37083
- return { kind: "incident", project: parts[2], status: "active", topic: parts[3].replace(/\.md$/, "") };
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"}`);
37084
37152
  }
37085
- return void 0;
37153
+ return messages;
37086
37154
  }
37087
- function groupableRecord(record2) {
37088
- if (record2.metadata.status !== "active" || !isCompactableKind(record2.metadata.kind)) {
37155
+ async function runGitCommand(dryRun, git, args, failureLabel) {
37156
+ if (dryRun) {
37157
+ console.log(`Would run: ${formatShellCommand(git, args)}`);
37089
37158
  return void 0;
37090
37159
  }
37091
- const project = normalizeOptionalMetadata(record2.metadata.project) ?? parseProjectFromUri(record2.uri);
37092
- if (!project) {
37093
- return void 0;
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"}`);
37094
37163
  }
37095
- const topic = topicForRecord(record2);
37096
- const groupKey = [record2.metadata.kind, project, topic ?? record2.uri].join("\0");
37097
- return { groupKey, project, record: record2, topic };
37098
- }
37099
- function topicForRecord(record2) {
37100
- return normalizeOptionalMetadata(record2.metadata.topic) ?? normalizeOptionalMetadata(branchFromBody(record2.body)) ?? topicFromUri(record2.uri);
37101
- }
37102
- function branchFromBody(body) {
37103
- const branch = /^branch:\s*(.+)$/m.exec(body)?.[1]?.trim();
37104
- return branch?.split(/\s+/)[0]?.replace(/[.,;:]+$/g, "");
37105
- }
37106
- function topicFromUri(uri) {
37107
- const name = (0, import_node_path3.basename)(uri).replace(/\.md$/, "");
37108
- return name.startsWith("threadnote-") ? void 0 : name;
37164
+ return result;
37109
37165
  }
37110
- function parseProjectFromUri(uri) {
37111
- const parts = uri.split("/memories/")[1]?.split("/").filter(Boolean) ?? [];
37112
- if ((parts[0] === "handoffs" || parts[0] === "incidents") && parts[1] === "active") {
37113
- return parts[2];
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}`);
37114
37171
  }
37115
- if (parts[0] === "durable" && parts[1] === "projects") {
37116
- return parts[2];
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
+ }
37117
37179
  }
37118
- return void 0;
37119
- }
37120
- function comparableMemoryBody(body) {
37121
- return stripHygieneSources(body).trim().replace(/\s+/g, " ");
37180
+ return shareSingleArtifact(config2, team, resolvedSourcePath, artifact, options);
37122
37181
  }
37123
- function stripHygieneSources(body) {
37124
- const index = body.indexOf(`
37125
- ${HYGIENE_SOURCES_HEADING}`);
37126
- if (index !== -1) {
37127
- return body.slice(0, index).trim();
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}`);
37128
37188
  }
37129
- return body.startsWith(HYGIENE_SOURCES_HEADING) ? "" : body.trim();
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
+ };
37222
+ }
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 };
37130
37251
  }
37131
- function preferredKeepRecord(records, topic) {
37132
- const stableRecords = records.filter((record2) => isStableRecord(record2, topic));
37133
- return sortedNewestFirst(stableRecords.length > 0 ? stableRecords : records)[0] ?? records[0];
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
+ };
37134
37367
  }
37135
- function isStableRecord(record2, topic) {
37136
- const recordTopic = topic ?? topicForRecord(record2);
37137
- return recordTopic !== void 0 && (0, import_node_path3.basename)(record2.uri) === `${uriSegment(recordTopic)}.md`;
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
+ };
37385
+ }
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
+ };
37138
37397
  }
37139
- function sortedNewestFirst(records) {
37140
- return [...records].sort((left, right) => {
37141
- const timestampDiff = timestampMs(right) - timestampMs(left);
37142
- return timestampDiff === 0 ? left.uri.localeCompare(right.uri) : timestampDiff;
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);
37143
37407
  });
37144
37408
  }
37145
- function timestampMs(record2) {
37146
- const parsed = Date.parse(record2.metadata.timestamp);
37147
- return Number.isFinite(parsed) ? parsed : 0;
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
+ `;
37148
37417
  }
37149
- function isStaleLookingHandoff(record2, now) {
37150
- if (record2.metadata.kind !== "handoff") {
37151
- return false;
37152
- }
37153
- if (now.getTime() - timestampMs(record2) < STALE_HANDOFF_AGE_MS) {
37154
- return false;
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
+ }
37155
37438
  }
37156
- return /\b(?:PR|pull request)\s+(?:OPEN|open|is open)|awaiting review|waiting for review|next steps?:\s*address PR review/i.test(
37157
- record2.body
37158
- );
37439
+ await visit(skillDir);
37440
+ return out.sort((a, b) => compareStrings(a.relativePath, b.relativePath));
37159
37441
  }
37160
- function groupBy(values, keyForValue) {
37161
- const groups = /* @__PURE__ */ new Map();
37162
- for (const value of values) {
37163
- const key = keyForValue(value);
37164
- groups.set(key, [...groups.get(key) ?? [], value]);
37442
+ function isIgnoredBundleFile(name) {
37443
+ if (name === ".DS_Store" || name === BUNDLE_MANIFEST_FILE || name === BUNDLE_INSTALL_METADATA_FILE) {
37444
+ return true;
37165
37445
  }
37166
- return groups;
37446
+ if (OV_SUMMARY_FILES.includes(name)) {
37447
+ return true;
37448
+ }
37449
+ return name.endsWith(".log") || name.endsWith(".threadnote-install.json");
37167
37450
  }
37168
- function compareGroupedRecordLists(left, right) {
37169
- return (left[0]?.groupKey ?? "").localeCompare(right[0]?.groupKey ?? "");
37451
+ function isProbablyBinary(buffer) {
37452
+ if (buffer.includes(0)) {
37453
+ return true;
37454
+ }
37455
+ try {
37456
+ new import_node_util.TextDecoder("utf-8", { fatal: true }).decode(buffer);
37457
+ return false;
37458
+ } catch (_err) {
37459
+ return true;
37460
+ }
37170
37461
  }
37171
- function dedupeByUri(items) {
37172
- const seen = /* @__PURE__ */ new Set();
37173
- const result = [];
37174
- for (const item of items) {
37175
- if (seen.has(item.uri)) {
37176
- continue;
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;
37177
37467
  }
37178
- seen.add(item.uri);
37179
- result.push(item);
37180
37468
  }
37181
- return result;
37182
- }
37183
- function formatMemoryDocument(title, metadata, body) {
37184
- const header = [
37185
- title,
37186
- `kind: ${metadata.kind}`,
37187
- `status: ${metadata.status}`,
37188
- metadata.project ? `project: ${metadata.project}` : void 0,
37189
- metadata.topic ? `topic: ${metadata.topic}` : void 0,
37190
- `source_agent_client: ${metadata.sourceAgentClient}`,
37191
- `timestamp: ${metadata.timestamp}`,
37192
- metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
37193
- metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
37194
- ].filter((line) => line !== void 0);
37195
- return [...header, "", body.trim()].join("\n");
37196
- }
37197
- function headerValue(header, key) {
37198
- const prefix = `${key}:`;
37199
- return header.split("\n").find((line) => line.startsWith(prefix))?.slice(prefix.length).trim();
37469
+ return void 0;
37200
37470
  }
37201
- function parseOptionalMemoryKind(value) {
37202
- if (!value) {
37203
- return void 0;
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";
37476
+ }
37204
37477
  }
37205
- if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
37206
- return value;
37478
+ for (const pattern of SCRUBBER_PATTERNS) {
37479
+ if (pattern.placeholder !== void 0 && pattern.regex.test(latin1)) {
37480
+ return pattern.name;
37481
+ }
37207
37482
  }
37208
37483
  return void 0;
37209
37484
  }
37210
- function parseOptionalMemoryStatus(value) {
37211
- if (!value) {
37485
+ async function readFileBytesIfExists(path) {
37486
+ try {
37487
+ return await (0, import_promises4.readFile)(path);
37488
+ } catch (_err) {
37212
37489
  return void 0;
37213
37490
  }
37214
- if (["active", "archived", "superseded"].includes(value)) {
37215
- return value;
37491
+ }
37492
+ function isBundleArtifact(artifact) {
37493
+ if (artifact.artifact.kind === "pack") {
37494
+ return true;
37216
37495
  }
37217
- return void 0;
37496
+ return artifact.members !== void 0 && artifact.members.length > 1;
37218
37497
  }
37219
- function normalizeOptionalMetadata(value) {
37220
- const trimmed = value?.trim();
37221
- return trimmed ? trimmed : void 0;
37222
- }
37223
- function isCompactableKind(kind) {
37224
- return kind === "durable" || kind === "handoff" || kind === "incident";
37498
+ function compareStrings(a, b) {
37499
+ return a < b ? -1 : a > b ? 1 : 0;
37225
37500
  }
37226
- function memoryKindPlural(kind) {
37227
- switch (kind) {
37228
- case "handoff":
37229
- return "handoffs";
37230
- case "incident":
37231
- return "incidents";
37232
- case "durable":
37233
- return "durable memories";
37234
- case "preference":
37235
- return "preferences";
37236
- case "smoke":
37237
- 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}`);
37238
37505
  }
37239
- }
37240
- function formatPlanSection(title, lines) {
37241
- if (lines.length === 0) {
37242
- return `${title}:
37243
- - 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}`);
37244
37509
  }
37245
- return [`${title}:`, ...lines.map((line) => `- ${line}`)].join("\n");
37246
- }
37247
-
37248
- // src/share.ts
37249
- var import_promises4 = require("node:fs/promises");
37250
- var import_node_os2 = require("node:os");
37251
- var import_node_path4 = require("node:path");
37252
- var TEAMS_FILE_VERSION = 1;
37253
- var SHARED_SEGMENT = "shared";
37254
- var SHAREABLE_MEMORY_KIND_DIRS = ["durable"];
37255
- var SHAREABLE_ARTIFACT_DIR = "agent-artifacts";
37256
- var SHAREABLE_TOP_LEVEL_DIRS = [...SHAREABLE_MEMORY_KIND_DIRS, SHAREABLE_ARTIFACT_DIR];
37257
- var ARTIFACT_INSTALL_METADATA_VERSION = 1;
37258
- var AUTO_SHARE_FETCH_INTERVAL_MS = 5 * 60 * 1e3;
37259
- var DEFAULT_GIT_REMOTE_NAME = "origin";
37260
- var SCRUBBER_PATTERNS = [
37261
- // Credentials: never redactable. Blocking is the only safe response —
37262
- // automated redaction risks false negatives that leave material in git.
37263
- { name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
37264
- { name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
37265
- { name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
37266
- { name: "GitHub fine-grained PAT", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
37267
- { name: "GitLab PAT", regex: /\bglpat-[A-Za-z0-9_-]{20,}/ },
37268
- { name: "bearer token", regex: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/i },
37269
- // Matches bare JWTs (three base64url segments). May surface a JWE token in
37270
- // legitimate docs; if that becomes noisy we can switch to warn-only.
37271
- { name: "JWT", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
37272
- { name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
37273
- // Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
37274
- // xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
37275
- { name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
37276
- // Soft leaks: block by default (so the agent sees them and decides), but
37277
- // allow opt-in redaction so curated memories with incidental matches can
37278
- // ship without a manual rewrite. Local home paths are the recurring
37279
- // real-world leak; the regexes greedily consume the whole path segment
37280
- // (including subdirectories) up to whitespace or common closing punctuation
37281
- // so redaction collapses an entire path to a single placeholder rather than
37282
- // leaving the subpath visible.
37283
- { name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
37284
- { name: "linux home path", placeholder: "<local-path>", regex: /\b\/home\/[^\s)>"'`,]+/ }
37285
- ];
37286
- function applyScrubber(content, { redact }) {
37287
- let cleaned = content;
37288
- const redactions = [];
37289
- for (const pattern of SCRUBBER_PATTERNS) {
37290
- if (!pattern.regex.test(cleaned)) {
37291
- continue;
37292
- }
37293
- if (!pattern.placeholder || !redact) {
37294
- return { blocker: pattern.name, cleaned: content, redactions: [] };
37295
- }
37296
- const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
37297
- const globalRegex = new RegExp(pattern.regex.source, flags);
37298
- const matches = cleaned.match(globalRegex) ?? [];
37299
- cleaned = cleaned.replace(globalRegex, pattern.placeholder);
37300
- 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}`);
37301
37513
  }
37302
- return { cleaned, redactions };
37303
- }
37304
- var autoShareStates = /* @__PURE__ */ new Map();
37305
- function startShareBackgroundFetch(config2) {
37306
- const state = autoShareState(config2);
37307
- if (state.timer) {
37308
- 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}`);
37309
37518
  }
37310
- void refreshShareUpdateState(config2, { force: true }).catch((err) => {
37311
- console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
37312
- });
37313
- state.timer = setInterval(() => {
37314
- void refreshShareUpdateState(config2, { force: false }).catch((err) => {
37315
- console.error(`share auto-fetch failed: ${err instanceof Error ? err.message : String(err)}`);
37316
- });
37317
- }, AUTO_SHARE_FETCH_INTERVAL_MS);
37318
- 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
+ };
37319
37542
  }
37320
- async function syncSharedReposBeforeAgentRead(config2) {
37321
- const state = autoShareState(config2);
37322
- return enqueueShareOperation(state, async () => {
37323
- await loadPendingReindexes(config2, state);
37324
- const warnings = await refreshShareUpdateStateLocked(config2, state, { force: false });
37325
- const syncTeams = /* @__PURE__ */ new Set([...state.behindTeams, ...state.pendingReindexes.keys()]);
37326
- if (syncTeams.size === 0) {
37327
- 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}").`);
37328
37549
  }
37329
- const syncedTeams = [];
37330
- const remainingBehind = new Set(state.behindTeams);
37331
- for (const team of syncTeams) {
37332
- try {
37333
- const warning2 = await runShareSyncQuiet(config2, state, { team });
37334
- if (warning2) {
37335
- warnings.push(warning2);
37336
- } else {
37337
- remainingBehind.delete(team);
37338
- syncedTeams.push(team);
37339
- }
37340
- } catch (err) {
37341
- warnings.push(
37342
- `Auto-sync for shared team "${team}" failed: ${err instanceof Error ? err.message : String(err)}`
37343
- );
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 });
37344
37558
  }
37559
+ return;
37345
37560
  }
37346
- state.behindTeams = remainingBehind;
37347
- state.lastCheckedAt = Date.now();
37348
- return { syncedTeams, warnings };
37349
- });
37350
- }
37351
- function autoShareState(config2) {
37352
- const key = `${config2.agentContextHome}:${config2.account}:${config2.user}`;
37353
- let state = autoShareStates.get(key);
37354
- if (!state) {
37355
- state = { behindTeams: /* @__PURE__ */ new Set(), lastCheckedAt: 0, pendingReindexes: /* @__PURE__ */ new Map() };
37356
- 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);
37357
37574
  }
37358
- 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));
37359
37579
  }
37360
- function pendingReindexesPath(config2) {
37361
- return (0, import_node_path4.join)(config2.agentContextHome, "share", "auto-sync-pending-reindexes.json");
37580
+ function escapeRegExp(value) {
37581
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37362
37582
  }
37363
- async function loadPendingReindexes(config2, state) {
37364
- const raw = await readFileIfExists(pendingReindexesPath(config2));
37365
- if (!raw) {
37366
- state.pendingReindexes = /* @__PURE__ */ new Map();
37367
- return;
37368
- }
37369
- const parsed = parseJsonConfigObject(raw);
37370
- if (!parsed || typeof parsed.teams !== "object" || parsed.teams === null || Array.isArray(parsed.teams)) {
37371
- state.pendingReindexes = /* @__PURE__ */ new Map();
37372
- return;
37373
- }
37374
- const pending = /* @__PURE__ */ new Map();
37375
- for (const [team, value] of Object.entries(parsed.teams)) {
37376
- if (!Array.isArray(value)) {
37377
- continue;
37378
- }
37379
- const changes = [];
37380
- for (const item of value) {
37381
- if (typeof item !== "object" || item === null || Array.isArray(item)) {
37382
- continue;
37383
- }
37384
- const entry = item;
37385
- if (typeof entry.path === "string" && typeof entry.relativePath === "string" && (entry.status === "added" || entry.status === "removed" || entry.status === "modified")) {
37386
- changes.push({ path: entry.path, relativePath: entry.relativePath, status: entry.status });
37387
- }
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
+ );
37388
37591
  }
37389
- if (changes.length > 0) {
37390
- 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);
37391
37623
  }
37392
37624
  }
37393
- 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));
37394
37629
  }
37395
- async function writePendingReindexes(config2, state) {
37396
- const path = pendingReindexesPath(config2);
37397
- if (state.pendingReindexes.size === 0) {
37398
- await (0, import_promises4.rm)(path, { force: true });
37399
- 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
+ };
37400
37655
  }
37401
- const contents = {
37402
- teams: Object.fromEntries(state.pendingReindexes),
37403
- 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
37404
37683
  };
37405
- await (0, import_promises4.mkdir)((0, import_node_path4.dirname)(path), { recursive: true });
37406
- const tempPath = `${path}.${process.pid}.tmp`;
37407
- await (0, import_promises4.writeFile)(tempPath, `${JSON.stringify(contents, void 0, 2)}
37408
- `, { encoding: "utf8", mode: 384 });
37409
- await (0, import_promises4.rename)(tempPath, path);
37410
37684
  }
37411
- async function refreshShareUpdateState(config2, options) {
37412
- const state = autoShareState(config2);
37413
- const warnings = await enqueueShareOperation(
37414
- state,
37415
- async () => refreshShareUpdateStateLocked(config2, state, options)
37416
- );
37417
- for (const warning2 of warnings) {
37418
- 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(", ")}`);
37419
37706
  }
37420
- }
37421
- async function refreshShareUpdateStateLocked(config2, state, options) {
37422
- const now = Date.now();
37423
- if (!options.force && state.lastCheckedAt > 0 && now - state.lastCheckedAt < AUTO_SHARE_FETCH_INTERVAL_MS) {
37424
- return [];
37707
+ if (manifest.deps.cli.length > 0) {
37708
+ lines.push(`- CLI: ${manifest.deps.cli.join(", ")}`);
37425
37709
  }
37426
- try {
37427
- const statuses = await fetchShareUpdateStatuses(config2);
37428
- const nextBehindTeams = new Set(state.behindTeams);
37429
- for (const status of statuses) {
37430
- if (status.warning) {
37431
- continue;
37432
- }
37433
- if (status.behind > 0) {
37434
- nextBehindTeams.add(status.team);
37435
- } else {
37436
- nextBehindTeams.delete(status.team);
37437
- }
37438
- }
37439
- state.behindTeams = nextBehindTeams;
37440
- return statuses.flatMap((status) => status.warning ? [status.warning] : []);
37441
- } finally {
37442
- state.lastCheckedAt = Date.now();
37710
+ if (manifest.deps.os.length > 0) {
37711
+ lines.push(`- OS: ${manifest.deps.os.join(", ")}`);
37443
37712
  }
37713
+ if (manifest.deps.mcp.length > 0) {
37714
+ lines.push(`- MCP (configure separately): ${manifest.deps.mcp.join(", ")}`);
37715
+ }
37716
+ lines.push("", `Install: threadnote share install-artifacts --kind pack --name ${artifact.name} --apply`, "");
37717
+ return lines.join("\n");
37444
37718
  }
37445
- function enqueueShareOperation(state, action) {
37446
- const previous = state.operationPromise ?? Promise.resolve();
37447
- const current = previous.catch(() => void 0).then(action);
37448
- state.operationPromise = current.catch(() => void 0);
37449
- return current;
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
+ `;
37450
37728
  }
37451
- async function fetchShareUpdateStatuses(config2) {
37452
- const teamsFile = await readTeamsFile(config2);
37453
- const teams = Object.entries(teamsFile.teams);
37454
- if (teams.length === 0) {
37455
- return [];
37456
- }
37457
- const git = await requiredExecutable("git");
37458
- const statuses = [];
37459
- for (const [name, team] of teams) {
37460
- const fetchResult = await runCommand(git, ["-C", team.worktree, "fetch", DEFAULT_GIT_REMOTE_NAME], {
37461
- allowFailure: true
37462
- });
37463
- if (fetchResult.exitCode !== 0) {
37464
- statuses.push({
37465
- behind: 0,
37466
- team: name,
37467
- warning: `Auto-sync check for shared team "${name}" failed: ${fetchResult.stderr.trim() || fetchResult.stdout.trim() || "unknown git fetch error"}`
37468
- });
37469
- continue;
37470
- }
37471
- const behind = await gitOutput(team.worktree, ["rev-list", "--count", "HEAD..@{u}"], false);
37472
- if (behind === void 0) {
37473
- statuses.push({
37474
- behind: 0,
37475
- team: name,
37476
- warning: `Auto-sync check for shared team "${name}" failed: could not read upstream behind count.`
37477
- });
37478
- continue;
37479
- }
37480
- statuses.push({ behind: Number.parseInt(behind, 10) || 0, team: name });
37481
- }
37482
- return statuses;
37483
- }
37484
- async function runShareSyncQuiet(config2, state, options) {
37485
- const team = await resolveTeam(config2, options.team);
37486
- const git = await requiredExecutable("git");
37487
- const worktree = team.config.worktree;
37488
- const pendingChanges = state.pendingReindexes.get(team.name);
37489
- if (pendingChanges && pendingChanges.length > 0) {
37490
- await applyAndPersistChanges(config2, team.config, state, pendingChanges, { quiet: true });
37491
- }
37492
- if (await hasUncommittedChanges(worktree)) {
37493
- return `Shared team "${team.name}" has uncommitted changes; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to publish or resolve them.`;
37494
- }
37495
- const ahead = await gitOutput(worktree, ["rev-list", "--count", "@{u}..HEAD"], false);
37496
- if (ahead === void 0) {
37497
- return `Shared team "${team.name}" upstream status is unknown; skipped automatic sync. Run \`threadnote share sync --team ${team.name}\` to inspect and resolve it.`;
37498
- }
37499
- if ((Number.parseInt(ahead, 10) || 0) > 0) {
37500
- 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.`;
37501
- }
37502
- const beforeRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37503
- const pullResult = await runCommand(git, ["-C", worktree, "rebase", "@{u}"], { allowFailure: true });
37504
- if (pullResult.exitCode !== 0) {
37505
- 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"))) {
37506
- throw new Error(
37507
- `Automatic share sync hit git conflicts in ${worktree}. Resolve them in-place, run \`git -C ${worktree} rebase --continue\` (or --abort), then rerun recall/read.`
37508
- );
37509
- }
37510
- throw new Error(
37511
- `Automatic share sync failed in ${worktree}: ${pullResult.stderr.trim() || pullResult.stdout.trim() || "unknown error"}`
37512
- );
37513
- }
37514
- const afterRev = await gitOutput(worktree, ["rev-parse", "HEAD"], false);
37515
- if (beforeRev && afterRev && beforeRev !== afterRev) {
37516
- const changes = await listChangedFiles(worktree, beforeRev, afterRev);
37517
- if (changes.length > 0) {
37518
- const stillPending = state.pendingReindexes.get(team.name) ?? [];
37519
- const combined = mergeChanges(stillPending, changes);
37520
- await applyAndPersistChanges(config2, team.config, state, combined, { quiet: true });
37521
- }
37522
- }
37523
- return void 0;
37524
- }
37525
- async function publishShareGitChange(worktree, relativePath, commitMessage, options = {}) {
37526
- const dryRun = options.dryRun === true;
37527
- const push = options.push !== false;
37528
- const verb = options.verb ?? "add";
37529
- const git = await requiredExecutable("git");
37530
- const messages = [];
37531
- const stageArgs = verb === "rm" ? ["-C", worktree, "rm", relativePath] : ["-C", worktree, "add", "--", relativePath];
37532
- const stageResult = await runGitCommand(dryRun, git, stageArgs, `git ${verb} failed`);
37533
- if (stageResult) {
37534
- messages.push(`git ${verb}: ${stageResult.stdout.trim() || "ok"}`);
37535
- }
37536
- if (dryRun) {
37537
- console.log(`Would run: ${formatShellCommand(git, ["-C", worktree, "commit", "-m", commitMessage])}`);
37538
- } else {
37539
- const commitResult = await runCommand(git, ["-C", worktree, "commit", "-m", commitMessage], { allowFailure: true });
37540
- if (commitResult.exitCode !== 0) {
37541
- const detail = commitResult.stdout.trim() || commitResult.stderr.trim();
37542
- if (/nothing to commit|no changes added/i.test(detail)) {
37543
- messages.push("git commit: nothing to commit (file already in tree)");
37544
- } else {
37545
- throw new Error(`git commit failed: ${detail || "unknown error"}`);
37546
- }
37547
- } else {
37548
- messages.push(`git commit: ${commitResult.stdout.trim().split("\n").slice(0, 2).join(" ")}`);
37549
- }
37550
- }
37551
- if (!push) {
37552
- messages.push("git push skipped (push=false)");
37553
- return messages;
37554
- }
37555
- const pushResult = await runGitCommand(
37556
- dryRun,
37557
- git,
37558
- ["-C", worktree, "push", DEFAULT_GIT_REMOTE_NAME],
37559
- "git push failed"
37560
- );
37561
- if (pushResult) {
37562
- messages.push(`git push: ${pushResult.stdout.trim() || pushResult.stderr.trim() || "ok"}`);
37563
- }
37564
- return messages;
37565
- }
37566
- async function runGitCommand(dryRun, git, args, failureLabel) {
37567
- if (dryRun) {
37568
- console.log(`Would run: ${formatShellCommand(git, args)}`);
37569
- return void 0;
37570
- }
37571
- const result = await runCommand(git, args, { allowFailure: true });
37572
- if (result.exitCode !== 0) {
37573
- throw new Error(`${failureLabel}: ${result.stderr.trim() || result.stdout.trim() || "unknown error"}`);
37574
- }
37575
- return result;
37729
+ function packSkillName(skillEntry) {
37730
+ const trimmed = skillEntry.replace(/\/SKILL\.md$/i, "");
37731
+ return (0, import_node_path3.basename)(trimmed);
37576
37732
  }
37577
- async function shareAgentArtifact(config2, sourcePath, options) {
37733
+ async function shareBundlePack(config2, manifestPath, options) {
37578
37734
  const team = await resolveTeam(config2, options.team);
37579
37735
  const dryRun = options.dryRun === true;
37580
37736
  const preview = options.preview === true;
37581
- const resolvedSourcePath = expandPath(sourcePath);
37582
- if (!await isRegularFileNoSymlink(resolvedSourcePath)) {
37583
- throw new Error(`Agent artifact source is not a regular file: ${resolvedSourcePath}`);
37584
- }
37585
- const artifact = inferShareArtifact(resolvedSourcePath, options);
37586
- const rawContent = await (0, import_promises4.readFile)(resolvedSourcePath, "utf8");
37587
- if (!rawContent.trim()) {
37588
- throw new Error(`Refusing to share empty agent artifact: ${resolvedSourcePath}`);
37589
- }
37590
- const scrub = applyScrubber(rawContent, { redact: options.redact === true });
37591
- const relativePath = sharedArtifactRelativePath(artifact);
37592
- const targetPath = (0, import_node_path4.join)(team.config.worktree, ...relativePath.split("/"));
37593
- 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 });
37594
37761
  const messages = [
37595
- `${preview ? "Previewing" : dryRun ? "Would share" : "Sharing"} ${artifact.kind} ${artifact.agent}/${artifact.name}`,
37596
- `Source: ${portablePath(resolvedSourcePath)}`,
37597
- `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}`
37598
37765
  ];
37766
+ const blockers = prepared.filter((entry) => entry.blocker !== void 0);
37599
37767
  if (preview) {
37600
- if (scrub.blocker) {
37601
- messages.push(`PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or pass --redact.`);
37602
- return {
37603
- artifact,
37604
- gitMessages: [],
37605
- messages,
37606
- sourcePath: resolvedSourcePath,
37607
- targetPath,
37608
- targetUri
37609
- };
37610
- }
37611
- for (const redaction of scrub.redactions) {
37612
- 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}`);
37613
37775
  }
37614
37776
  return {
37615
37777
  artifact,
37616
37778
  gitMessages: [],
37617
37779
  messages,
37618
- previewContent: scrub.cleaned,
37619
- sourcePath: resolvedSourcePath,
37620
- targetPath,
37621
- targetUri
37780
+ previewContent: indexScrub.cleaned,
37781
+ sourcePath: resolvedManifest,
37782
+ targetPath: indexTargetPath,
37783
+ targetUri: indexTargetUri
37622
37784
  };
37623
37785
  }
37624
- if (scrub.blocker) {
37786
+ const indexResidual = residualRewriteRoot(indexScrub.cleaned, rewriteRoots);
37787
+ if (indexScrub.blocker !== void 0 || indexResidual !== void 0) {
37625
37788
  throw new Error(
37626
- `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`}.`
37627
37790
  );
37628
37791
  }
37629
- for (const redaction of scrub.redactions) {
37630
- 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
+ );
37631
37796
  }
37632
- const content = scrub.cleaned;
37633
- const existingContent = await readFileIfExists(targetPath) ?? void 0;
37634
- 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) {
37635
37802
  throw new Error(
37636
- `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("; ")}`
37637
37828
  );
37638
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
+ }
37639
37838
  if (dryRun) {
37640
- 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
+ };
37641
37848
  }
37642
37849
  const ov = await openVikingCliForMode(dryRun);
37643
- const ovHasResource = !dryRun && await vikingResourceExists(ov, config2, targetUri);
37644
- await ensureSharedDirectoryChain(config2, ov, targetUri, dryRun, { quiet: true });
37645
- await writeMemoryFile(config2, ov, targetUri, content, ovHasResource ? "replace" : "create", dryRun, { quiet: true });
37646
- const message = options.message ?? `share: publish ${relativePath}`;
37647
- 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, {
37648
37932
  dryRun,
37649
37933
  push: options.push
37650
37934
  });
37651
- 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
+ };
37652
37943
  }
37653
37944
  async function listSharedAgentArtifacts(config2, options = {}) {
37654
37945
  const syncResult = await maybeSyncSharedArtifacts(config2, options);
@@ -37691,6 +37982,10 @@ async function installSharedAgentArtifacts(config2, options) {
37691
37982
  }
37692
37983
  let installedCount = 0;
37693
37984
  for (const artifact of artifacts) {
37985
+ if (isBundleArtifact(artifact)) {
37986
+ installedCount += await installBundleArtifact(artifact, options, dryRun, messages);
37987
+ continue;
37988
+ }
37694
37989
  const label = sharedArtifactLabel(artifact.artifact);
37695
37990
  const state = await sharedArtifactInstallState(artifact);
37696
37991
  if (dryRun) {
@@ -37707,7 +38002,7 @@ async function installSharedAgentArtifacts(config2, options) {
37707
38002
  messages.push(`Already installed ${label}: ${portablePath(artifact.installPath)}`);
37708
38003
  continue;
37709
38004
  }
37710
- await ensureDirectory((0, import_node_path4.dirname)(artifact.installPath), false);
38005
+ await ensureDirectory((0, import_node_path3.dirname)(artifact.installPath), false);
37711
38006
  await (0, import_promises4.writeFile)(artifact.installPath, state.sourceContent, { encoding: "utf8", mode: 384 });
37712
38007
  await writeSharedArtifactMetadata(artifact, state.sourceSha);
37713
38008
  installedCount += 1;
@@ -37736,10 +38031,10 @@ function normalizeTeamName(input) {
37736
38031
  return candidate;
37737
38032
  }
37738
38033
  function teamsFilePath(config2) {
37739
- return (0, import_node_path4.join)(config2.agentContextHome, "share", "teams.json");
38034
+ return (0, import_node_path3.join)(config2.agentContextHome, "share", "teams.json");
37740
38035
  }
37741
38036
  function teamWorktreePath(config2, team) {
37742
- return (0, import_node_path4.join)(
38037
+ return (0, import_node_path3.join)(
37743
38038
  config2.agentContextHome,
37744
38039
  "data",
37745
38040
  "viking",
@@ -37752,7 +38047,7 @@ function teamWorktreePath(config2, team) {
37752
38047
  );
37753
38048
  }
37754
38049
  function teamGitdirPath(config2, team) {
37755
- 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`);
37756
38051
  }
37757
38052
  async function readTeamsFile(config2) {
37758
38053
  const path = teamsFilePath(config2);
@@ -37808,7 +38103,7 @@ async function resolveTeam(config2, requested) {
37808
38103
  return { config: found, name: wantName };
37809
38104
  }
37810
38105
  function workfileToVikingUri(config2, team, filePath) {
37811
- 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("/");
37812
38107
  return `viking://user/${uriSegment(config2.user)}/memories/${SHARED_SEGMENT}/${team.name}/${rel}`;
37813
38108
  }
37814
38109
  function isInSharedNamespace(config2, uri) {
@@ -37866,15 +38161,15 @@ async function isRegularFileNoSymlink(path) {
37866
38161
  }
37867
38162
  }
37868
38163
  function inferShareArtifact(path, options) {
37869
- const normalizedPath = path.split(import_node_path4.sep).join("/");
37870
- 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);
37871
38166
  const lowerFileName = fileName.toLowerCase();
37872
38167
  const lowerPath = normalizedPath.toLowerCase();
37873
38168
  const inferredKind = lowerFileName === "skill.md" ? "skill" : lowerPath.includes("/.claude/commands/") && lowerFileName.endsWith(".md") ? "command" : void 0;
37874
38169
  const inferredAgent = lowerPath.includes("/.codex/skills/") ? "codex" : lowerPath.includes("/.claude/skills/") || lowerPath.includes("/.claude/commands/") ? "claude" : void 0;
37875
38170
  const extensionIndex = fileName.lastIndexOf(".");
37876
38171
  const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
37877
- 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;
37878
38173
  const kind = options.kind ?? inferredKind;
37879
38174
  const agent = options.agent ?? inferredAgent;
37880
38175
  const name = options.name ?? inferredName;
@@ -37915,10 +38210,13 @@ function sharedArtifactFromRelativePath(relativePath) {
37915
38210
  if (parts.length === 4 && parts[1] === "commands" && parts[2] === "claude" && parts[3].endsWith(".md")) {
37916
38211
  return { agent: "claude", kind: "command", name: parts[3].slice(0, -".md".length) };
37917
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
+ }
37918
38216
  return void 0;
37919
38217
  }
37920
38218
  async function collectSharedArtifacts(worktree, team) {
37921
- const root = (0, import_node_path4.join)(worktree, SHAREABLE_ARTIFACT_DIR);
38219
+ const root = (0, import_node_path3.join)(worktree, SHAREABLE_ARTIFACT_DIR);
37922
38220
  if (!await isDirectory(root)) {
37923
38221
  return [];
37924
38222
  }
@@ -37926,7 +38224,7 @@ async function collectSharedArtifacts(worktree, team) {
37926
38224
  async function visit(path) {
37927
38225
  const entries = await (0, import_promises4.readdir)(path, { withFileTypes: true });
37928
38226
  for (const entry of entries) {
37929
- const full = (0, import_node_path4.join)(path, entry.name);
38227
+ const full = (0, import_node_path3.join)(path, entry.name);
37930
38228
  if (entry.isDirectory()) {
37931
38229
  await visit(full);
37932
38230
  continue;
@@ -37934,23 +38232,100 @@ async function collectSharedArtifacts(worktree, team) {
37934
38232
  if (!entry.isFile() || !entry.name.endsWith(".md")) {
37935
38233
  continue;
37936
38234
  }
37937
- 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("/");
37938
38236
  const artifact = sharedArtifactFromRelativePath(relativePath);
37939
38237
  if (artifact === void 0) {
37940
38238
  continue;
37941
38239
  }
37942
- out.push({
37943
- artifact,
37944
- installPath: sharedArtifactInstallPath(team, artifact),
37945
- sourcePath: full,
37946
- sourceRelativePath: relativePath,
37947
- team
37948
- });
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
+ }
37949
38259
  }
37950
38260
  }
37951
38261
  await visit(root);
37952
38262
  return out.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
37953
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
+ }
37954
38329
  function filterSharedArtifacts(artifacts, options) {
37955
38330
  const name = options.name === void 0 ? void 0 : uriSegment(options.name);
37956
38331
  return artifacts.filter((artifact) => {
@@ -37973,13 +38348,228 @@ async function maybeSyncSharedArtifacts(config2, options) {
37973
38348
  return syncSharedReposBeforeAgentRead(config2);
37974
38349
  }
37975
38350
  async function sharedArtifactInstallStatus(artifact) {
38351
+ if (isBundleArtifact(artifact)) {
38352
+ return sharedBundleInstallStatus(artifact);
38353
+ }
37976
38354
  return (await sharedArtifactInstallState(artifact)).status;
37977
38355
  }
37978
- async function sharedArtifactInstallState(artifact) {
37979
- const sourceContent = await (0, import_promises4.readFile)(artifact.sourcePath, "utf8");
37980
- const sourceSha = sha256(sourceContent);
37981
- const existingContent = await readFileIfExists(artifact.installPath) ?? void 0;
37982
- if (existingContent === void 0) {
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
+ }
38568
+ async function sharedArtifactInstallState(artifact) {
38569
+ const sourceContent = await (0, import_promises4.readFile)(artifact.sourcePath, "utf8");
38570
+ const sourceSha = sha256(sourceContent);
38571
+ const existingContent = await readFileIfExists(artifact.installPath) ?? void 0;
38572
+ if (existingContent === void 0) {
37983
38573
  return { sourceContent, sourceSha, status: "not_installed" };
37984
38574
  }
37985
38575
  const existingSha = sha256(existingContent);
@@ -38045,7 +38635,7 @@ async function writeSharedArtifactMetadata(artifact, sourceSha) {
38045
38635
  version: ARTIFACT_INSTALL_METADATA_VERSION
38046
38636
  };
38047
38637
  const metadataPath = sharedArtifactMetadataPath(artifact);
38048
- await ensureDirectory((0, import_node_path4.dirname)(metadataPath), false);
38638
+ await ensureDirectory((0, import_node_path3.dirname)(metadataPath), false);
38049
38639
  await (0, import_promises4.writeFile)(metadataPath, `${JSON.stringify(metadata, void 0, 2)}
38050
38640
  `, { encoding: "utf8", mode: 384 });
38051
38641
  }
@@ -38097,336 +38687,834 @@ function sharedArtifactLabel(artifact) {
38097
38687
  return `${artifact.kind} ${artifact.agent}/${artifact.name}`;
38098
38688
  }
38099
38689
  function sharedArtifactInstallPath(team, artifact) {
38100
- if (artifact.kind === "skill" && artifact.agent === "codex") {
38101
- 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]);
38714
+ }
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
+ );
38102
38856
  }
38103
- if (artifact.kind === "skill" && artifact.agent === "claude") {
38104
- return (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".claude", "skills", "threadnote", team, artifact.name, "SKILL.md");
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.");
38105
39276
  }
38106
- return (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".claude", "commands", "threadnote", team, `${artifact.name}.md`);
39277
+ return lines.join("\n");
38107
39278
  }
38108
- function stripPersonalProvenance(content) {
38109
- const lines = content.split("\n");
38110
- let headerEnd = lines.length;
38111
- for (let index = 0; index < lines.length; index += 1) {
38112
- if (lines[index].trim() === "") {
38113
- headerEnd = index;
38114
- 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;
38115
39288
  }
39289
+ groups.set(item.groupKey, [...groups.get(item.groupKey) ?? [], item]);
38116
39290
  }
38117
- const cleaned = [];
38118
- for (let index = 0; index < headerEnd; index += 1) {
38119
- if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
39291
+ for (const group of [...groups.values()].sort(compareGroupedRecordLists)) {
39292
+ if (group.length < 3) {
38120
39293
  continue;
38121
39294
  }
38122
- cleaned.push(lines[index]);
38123
- }
38124
- for (let index = headerEnd; index < lines.length; index += 1) {
38125
- cleaned.push(lines[index]);
38126
- }
38127
- return cleaned.join("\n");
38128
- }
38129
- async function ensureSharedDirectoryChain(config2, ov, memoryUri, dryRun, options = {}) {
38130
- const directoryUri = parentUri(memoryUri);
38131
- for (const uri of sharedDirectoryChain(config2, directoryUri)) {
38132
- const args = withIdentity(config2, ["stat", uri]);
38133
- if (dryRun) {
38134
- if (options.quiet !== true) {
38135
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38136
- }
39295
+ const first = group[0];
39296
+ if (!first || !isCompactableKind(first.record.metadata.kind)) {
38137
39297
  continue;
38138
39298
  }
38139
- const statResult = await runCommand(ov, args, { allowFailure: true });
38140
- if (statResult.exitCode === 0) {
39299
+ const topic = first.topic;
39300
+ if (!topic) {
38141
39301
  continue;
38142
39302
  }
38143
- if (options.quiet === true) {
38144
- await runCommand(ov, withIdentity(config2, ["mkdir", uri, "--description", "Threadnote shared context."]));
38145
- } else {
38146
- 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;
38147
39318
  }
39319
+ nudges.push(
39320
+ `Many active handoffs surfaced for ${project}; run compact_context({"project":"${project}","dryRun":true}).`
39321
+ );
38148
39322
  }
39323
+ return [...new Set(nudges)];
38149
39324
  }
38150
- function parentUri(uri) {
38151
- const lastSlash = uri.lastIndexOf("/");
38152
- 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)];
38153
39337
  }
38154
- function sharedDirectoryChain(config2, directoryUri) {
38155
- const prefix = `viking://user/${uriSegment(config2.user)}/memories/${SHARED_SEGMENT}/`;
38156
- if (!directoryUri.startsWith(prefix)) {
38157
- 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;
38158
39342
  }
38159
- const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
38160
- const chain = [];
38161
- for (let index = 1; index <= parts.length; index += 1) {
38162
- 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;
38163
39347
  }
38164
- return chain;
38165
- }
38166
- async function writeMemoryFile(config2, ov, uri, content, initialMode, dryRun, options = {}) {
38167
- if (dryRun) {
38168
- const args = withIdentity(config2, [
38169
- "write",
38170
- uri,
38171
- "--from-file",
38172
- "<staged temp file>",
38173
- "--mode",
38174
- initialMode,
38175
- "--wait",
38176
- "--timeout",
38177
- "120"
38178
- ]);
38179
- if (options.quiet !== true) {
38180
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38181
- }
38182
- 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$/, "") };
38183
39350
  }
38184
- const stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path4.join)((0, import_node_os2.tmpdir)(), "threadnote-share-"));
38185
- const tempPath = (0, import_node_path4.join)(stagingDir, "body.txt");
38186
- try {
38187
- await (0, import_promises4.writeFile)(tempPath, content, { encoding: "utf8", mode: 384 });
38188
- await writeOvFileWithRetry(config2, ov, uri, tempPath, initialMode, options);
38189
- await refreshMemoryIndex(config2, ov, uri, options);
38190
- } finally {
38191
- 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$/, "") };
38192
39356
  }
39357
+ return void 0;
38193
39358
  }
38194
- var BUSY_RETRY_BACKOFF_MS = [2e3, 5e3, 1e4, 2e4, 3e4];
38195
- async function writeOvFileWithRetry(config2, ov, uri, fromFile, initialMode, options = {}) {
38196
- const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38197
- const existedBeforeWrite = await vikingResourceExists(ov, config2, uri);
38198
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38199
- const existsNow = attempt === 0 ? existedBeforeWrite : await vikingResourceExists(ov, config2, uri);
38200
- const ourWriteLanded = existsNow && !existedBeforeWrite;
38201
- const mode = attempt === 0 ? initialMode : ourWriteLanded ? "replace" : initialMode;
38202
- const args = withIdentity(config2, [
38203
- "write",
38204
- uri,
38205
- "--from-file",
38206
- fromFile,
38207
- "--mode",
38208
- mode,
38209
- "--wait",
38210
- "--timeout",
38211
- "120"
38212
- ]);
38213
- if (options.quiet !== true) {
38214
- console.log(`${attempt === 0 ? "Running" : "Retrying"}: ${formatShellCommand(ov, args)}`);
38215
- }
38216
- const result = await runCommand(ov, args, { allowFailure: true });
38217
- if (result.exitCode === 0) {
38218
- if (options.quiet !== true && result.stdout.trim()) {
38219
- console.log(result.stdout.trim());
38220
- }
38221
- if (options.quiet !== true && result.stderr.trim()) {
38222
- console.error(result.stderr.trim());
38223
- }
38224
- return;
38225
- }
38226
- if (isTransientOvFailure(result.stderr, result.stdout) && await vikingResourceExists(ov, config2, uri) && !existedBeforeWrite) {
38227
- if (options.quiet !== true) {
38228
- console.log(
38229
- "OpenViking accepted the write but returned an error before the wait completed; draining the queue."
38230
- );
38231
- }
38232
- await waitForOvQueue(ov, config2, options);
38233
- return;
38234
- }
38235
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38236
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38237
- }
38238
- if (isResourceBusyFailure(result.stderr, result.stdout)) {
38239
- await waitForOvQueue(ov, config2, options);
38240
- await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38241
- } else {
38242
- await sleep(1e3 * (attempt + 1));
38243
- }
39359
+ function groupableRecord(record2) {
39360
+ if (record2.metadata.status !== "active" || !isCompactableKind(record2.metadata.kind)) {
39361
+ return void 0;
39362
+ }
39363
+ const project = normalizeOptionalMetadata(record2.metadata.project) ?? parseProjectFromUri(record2.uri);
39364
+ if (!project) {
39365
+ return void 0;
38244
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 };
38245
39370
  }
38246
- async function refreshMemoryIndex(config2, ov, uri, options = {}) {
38247
- const result = await runCommand(
38248
- ov,
38249
- withIdentity(config2, ["reindex", uri, "--mode", "semantic_and_vectors", "--wait", "true"]),
38250
- { allowFailure: true }
38251
- );
38252
- if (result.exitCode === 0) {
38253
- if (options.quiet !== true && result.stdout.trim()) {
38254
- console.log(result.stdout.trim());
38255
- }
38256
- if (options.quiet !== true && result.stderr.trim()) {
38257
- console.error(result.stderr.trim());
38258
- }
38259
- 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];
38260
39386
  }
38261
- if (options.quiet !== true) {
38262
- console.error(
38263
- `Memory stored, but index refresh failed for ${uri}: ${result.stderr.trim() || result.stdout.trim()}`
38264
- );
39387
+ if (parts[0] === "durable" && parts[1] === "projects") {
39388
+ return parts[2];
39389
+ }
39390
+ return void 0;
39391
+ }
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();
38265
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;
38266
39420
  }
38267
- async function waitForOvQueue(ov, config2, options = {}) {
38268
- const result = await runCommand(ov, withIdentity(config2, ["wait", "--timeout", "120"]), { allowFailure: true });
38269
- if (options.quiet !== true && result.stdout.trim()) {
38270
- console.log(result.stdout.trim());
39421
+ function isStaleLookingHandoff(record2, now) {
39422
+ if (record2.metadata.kind !== "handoff") {
39423
+ return false;
38271
39424
  }
38272
- if (options.quiet !== true && result.stderr.trim()) {
38273
- console.error(result.stderr.trim());
39425
+ if (now.getTime() - timestampMs(record2) < STALE_HANDOFF_AGE_MS) {
39426
+ return false;
38274
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
+ );
38275
39431
  }
38276
- function isTransientOvFailure(stderr, stdout2) {
38277
- const output = `${stderr}
38278
- ${stdout2}`.toLowerCase();
38279
- 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");
38280
- }
38281
- function isResourceBusyFailure(stderr, stdout2) {
38282
- const output = `${stderr}
38283
- ${stdout2}`.toLowerCase();
38284
- 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;
38285
39439
  }
38286
- async function ingestSingleFile(ov, config2, uri, filePath, initialMode, options = {}) {
38287
- const content = await (0, import_promises4.readFile)(filePath, "utf8");
38288
- await writeMemoryFile(config2, ov, uri, content, initialMode, false, options);
39440
+ function compareGroupedRecordLists(left, right) {
39441
+ return (left[0]?.groupKey ?? "").localeCompare(right[0]?.groupKey ?? "");
38289
39442
  }
38290
- async function removeMemoryUri(config2, ov, uri, dryRun, options = {}) {
38291
- const args = withIdentity(config2, ["rm", uri]);
38292
- if (dryRun) {
38293
- if (options.quiet !== true) {
38294
- console.log(`Would run: ${formatShellCommand(ov, args)}`);
38295
- }
38296
- return;
38297
- }
38298
- const maxAttempts = BUSY_RETRY_BACKOFF_MS.length + 1;
38299
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
38300
- const result = await runCommand(ov, args, { allowFailure: true });
38301
- if (result.exitCode === 0) {
38302
- if (options.quiet !== true && result.stdout.trim()) {
38303
- console.log(result.stdout.trim());
38304
- }
38305
- return;
38306
- }
38307
- if (!isTransientOvFailure(result.stderr, result.stdout) || attempt === maxAttempts - 1) {
38308
- throw new Error(`${formatShellCommand(ov, args)} failed: ${result.stderr || result.stdout}`);
38309
- }
38310
- if (isResourceBusyFailure(result.stderr, result.stdout)) {
38311
- await waitForOvQueue(ov, config2, options);
38312
- await sleep(BUSY_RETRY_BACKOFF_MS[attempt] ?? 3e4);
38313
- } else {
38314
- 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;
38315
39449
  }
39450
+ seen.add(item.uri);
39451
+ result.push(item);
38316
39452
  }
39453
+ return result;
38317
39454
  }
38318
- async function vikingResourceExists(ov, config2, uri) {
38319
- const result = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
38320
- 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");
38321
39468
  }
38322
- async function hasUncommittedChanges(worktree) {
38323
- const result = await runCommand("git", ["-C", worktree, "status", "--porcelain"], { allowFailure: true });
38324
- 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();
38325
39472
  }
38326
- async function gitOutput(worktree, args, dryRun) {
38327
- if (dryRun) {
39473
+ function parseOptionalMemoryKind(value) {
39474
+ if (!value) {
38328
39475
  return void 0;
38329
39476
  }
38330
- const result = await runCommand("git", ["-C", worktree, ...args], { allowFailure: true });
38331
- if (result.exitCode !== 0) {
38332
- return void 0;
39477
+ if (["durable", "handoff", "incident", "preference", "smoke"].includes(value)) {
39478
+ return value;
38333
39479
  }
38334
- return result.stdout.trim();
39480
+ return void 0;
38335
39481
  }
38336
- async function listChangedFiles(worktree, beforeRev, afterRev) {
38337
- const result = await runCommand("git", ["-C", worktree, "diff", "--name-status", "-z", `${beforeRev}..${afterRev}`], {
38338
- allowFailure: true
38339
- });
38340
- if (result.exitCode !== 0) {
38341
- return [];
39482
+ function parseOptionalMemoryStatus(value) {
39483
+ if (!value) {
39484
+ return void 0;
38342
39485
  }
38343
- const entries = result.stdout.split("\0").filter((part) => part.length > 0);
38344
- const changes = [];
38345
- for (let index = 0; index < entries.length; ) {
38346
- const raw = entries[index];
38347
- const head = raw.slice(0, 1);
38348
- if (head === "R" || head === "C") {
38349
- const oldRel = entries[index + 1];
38350
- const newRel = entries[index + 2];
38351
- if (oldRel && newRel) {
38352
- changes.push({ path: (0, import_node_path4.join)(worktree, oldRel), relativePath: oldRel, status: "removed" });
38353
- changes.push({ path: (0, import_node_path4.join)(worktree, newRel), relativePath: newRel, status: "added" });
38354
- }
38355
- index += 3;
38356
- continue;
38357
- }
38358
- const rel = entries[index + 1];
38359
- if (rel) {
38360
- const status = head === "A" ? "added" : head === "D" ? "removed" : "modified";
38361
- changes.push({ path: (0, import_node_path4.join)(worktree, rel), relativePath: rel, status });
38362
- }
38363
- index += 2;
39486
+ if (["active", "archived", "superseded"].includes(value)) {
39487
+ return value;
38364
39488
  }
38365
- return changes;
39489
+ return void 0;
38366
39490
  }
38367
- async function applyChangesToOpenViking(config2, team, changes, options = {}) {
38368
- const ov = await openVikingCliForMode(false);
38369
- const failed = [];
38370
- for (const change of changes) {
38371
- if (!change.relativePath.endsWith(".md")) {
38372
- continue;
38373
- }
38374
- const firstSegment = change.relativePath.split("/")[0];
38375
- if (!SHAREABLE_TOP_LEVEL_DIRS.includes(firstSegment)) {
38376
- continue;
38377
- }
38378
- const uri = workfileToVikingUri(config2, team, change.path);
38379
- try {
38380
- if (change.status === "removed") {
38381
- await removeMemoryUri(config2, ov, uri, false, options);
38382
- continue;
38383
- }
38384
- if (!await isFile(change.path)) {
38385
- continue;
38386
- }
38387
- const ovHasResource = await vikingResourceExists(ov, config2, uri);
38388
- if (ovHasResource) {
38389
- 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)";
38390
- if (options.quiet !== true) {
38391
- console.warn(`share sync: ${uri}: ${reason}.`);
38392
- }
38393
- }
38394
- await ensureSharedDirectoryChain(config2, ov, uri, false, options);
38395
- const writeMode = ovHasResource ? "replace" : "create";
38396
- await ingestSingleFile(ov, config2, uri, change.path, writeMode, options);
38397
- } catch (err) {
38398
- const message = err instanceof Error ? err.message : String(err);
38399
- if (options.quiet !== true) {
38400
- console.warn(`share sync: ${uri}: ingest failed \u2014 will retry on the next sync. ${message}`);
38401
- }
38402
- failed.push(change);
38403
- }
38404
- }
38405
- return { failed };
39491
+ function normalizeOptionalMetadata(value) {
39492
+ const trimmed = value?.trim();
39493
+ return trimmed ? trimmed : void 0;
38406
39494
  }
38407
- function mergeChanges(...lists) {
38408
- const map3 = /* @__PURE__ */ new Map();
38409
- for (const list of lists) {
38410
- for (const change of list) {
38411
- map3.set(change.relativePath, change);
38412
- }
38413
- }
38414
- return [...map3.values()];
39495
+ function isCompactableKind(kind) {
39496
+ return kind === "durable" || kind === "handoff" || kind === "incident";
38415
39497
  }
38416
- async function applyAndPersistChanges(config2, team, state, changes, options = {}) {
38417
- if (changes.length === 0) {
38418
- 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";
38419
39510
  }
38420
- state.pendingReindexes.set(team.name, changes);
38421
- await writePendingReindexes(config2, state);
38422
- const result = await applyChangesToOpenViking(config2, team, changes, options);
38423
- if (result.failed.length > 0) {
38424
- state.pendingReindexes.set(team.name, result.failed);
38425
- } else {
38426
- state.pendingReindexes.delete(team.name);
39511
+ }
39512
+ function formatPlanSection(title, lines) {
39513
+ if (lines.length === 0) {
39514
+ return `${title}:
39515
+ - none`;
38427
39516
  }
38428
- await writePendingReindexes(config2, state);
38429
- return result;
39517
+ return [`${title}:`, ...lines.map((line) => `- ${line}`)].join("\n");
38430
39518
  }
38431
39519
 
38432
39520
  // src/mcp_server.ts
@@ -38509,6 +39597,15 @@ function registerTools(server, config2) {
38509
39597
  );
38510
39598
  registerArchiveTool(server, config2, "archive", "Compatibility alias for archive_context.");
38511
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
+ );
38512
39609
  server.registerTool(
38513
39610
  "forget",
38514
39611
  {
@@ -38655,9 +39752,10 @@ function registerTools(server, config2) {
38655
39752
  "share_skill",
38656
39753
  {
38657
39754
  annotations: { readOnlyHint: false, destructiveHint: true },
38658
- 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.",
38659
39756
  inputSchema: {
38660
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"),
38661
39759
  force: external_exports.boolean().optional().describe("Replace an existing shared artifact with different content"),
38662
39760
  kind: external_exports.enum(["skill", "command"]).optional().describe("Artifact kind when path inference is ambiguous"),
38663
39761
  message: external_exports.string().optional().describe('Commit message override; defaults to "share: publish <path>"'),
@@ -38669,7 +39767,7 @@ function registerTools(server, config2) {
38669
39767
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38670
39768
  }
38671
39769
  },
38672
- async ({ agent, force, kind, message, name, path, preview, push, redact, team }) => {
39770
+ async ({ agent, allowBinary, force, kind, message, name, path, preview, push, redact, team }) => {
38673
39771
  const checkedPath = requiredText(path, "share_skill", "path", {
38674
39772
  path: "~/.codex/skills/example/SKILL.md"
38675
39773
  });
@@ -38678,6 +39776,7 @@ function registerTools(server, config2) {
38678
39776
  }
38679
39777
  return runShareSkillTool(config2, checkedPath.value, {
38680
39778
  agent,
39779
+ allowBinary,
38681
39780
  force,
38682
39781
  kind,
38683
39782
  message,
@@ -38689,14 +39788,40 @@ function registerTools(server, config2) {
38689
39788
  });
38690
39789
  }
38691
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
+ );
38692
39817
  server.registerTool(
38693
39818
  "list_shared_skills",
38694
39819
  {
38695
39820
  annotations: { readOnlyHint: true, destructiveHint: false },
38696
- 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.",
38697
39822
  inputSchema: {
38698
39823
  agent: external_exports.enum(["codex", "claude"]).optional().describe("Optional agent filter"),
38699
- kind: external_exports.enum(["skill", "command"]).optional().describe("Optional kind filter"),
39824
+ kind: external_exports.enum(["skill", "command", "pack"]).optional().describe("Optional kind filter"),
38700
39825
  name: external_exports.string().optional().describe("Optional shared artifact name filter"),
38701
39826
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38702
39827
  }
@@ -38712,7 +39837,7 @@ function registerTools(server, config2) {
38712
39837
  agent: external_exports.enum(["codex", "claude"]).optional().describe("Agent owner; required when name is ambiguous"),
38713
39838
  dryRun: external_exports.boolean().optional().describe("Preview install without writing local files"),
38714
39839
  force: external_exports.boolean().optional().describe("Replace an existing installed artifact with different content"),
38715
- 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"),
38716
39841
  name: external_exports.string().optional().describe("Required shared artifact name to install"),
38717
39842
  team: external_exports.string().optional().describe("Team name; defaults to the configured default team")
38718
39843
  }
@@ -40098,6 +41223,34 @@ async function runShareSkillTool(config2, sourcePath, options) {
40098
41223
  return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
40099
41224
  }
40100
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
+ }
40101
41254
  async function runListSharedSkillsTool(config2, options) {
40102
41255
  try {
40103
41256
  const result = await listSharedAgentArtifacts(config2, options);