replen 1.0.32 → 1.0.34

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.
@@ -0,0 +1,163 @@
1
+ // Zero-touch repo auto-registration, run from the SessionStart hook.
2
+ //
3
+ // The problem this closes: registration used to happen ONLY when the user ran
4
+ // `npx replen sync-projects` by hand. A repo they cloned/created and never
5
+ // manually synced was invisible to Replen — so it never got matched, and the
6
+ // user had to remember a step. This folds discovery into the hook that
7
+ // already runs every session, so a repo's *identity* registers itself the
8
+ // first time the user opens Claude Code anywhere near it.
9
+ //
10
+ // Two triggers, both bounded and silent (this is on the session-open critical
11
+ // path; it must never prompt, log to the user, slow things down, or throw):
12
+ // 1. cwd fast-path — the repo the user just opened. Cheap (a single-repo
13
+ // walk), runs every session, catches the "I just made this repo" case
14
+ // immediately.
15
+ // 2. throttled full walk — the whole portfolio, at most once per
16
+ // FULL_SCAN_INTERVAL. Belt-and-suspenders for repos cloned but not yet
17
+ // opened in Claude Code.
18
+ //
19
+ // Identity only: this registers owner/name (+ manifest-derived tags) via the
20
+ // same local-FS `discoverProjects` the manual sync uses. No code is read, no
21
+ // LLM is called. Capability/facet profiling is Part 2 (onboard-on-first-visit).
22
+ import { execSync } from "node:child_process";
23
+ import { discoverProjects } from "./discover-projects.js";
24
+ import { readKnownRepos, mergeKnownRepos } from "./known-repos.js";
25
+ import { rootsFromClaudeJson, rootsFromConfig, rootsFromEnv, rootsFromHardcoded, } from "./discover-roots.js";
26
+ // How often the full-portfolio filesystem walk may run. The walk is local-FS
27
+ // only but still touches every repo under the user's roots, so we don't want
28
+ // it on every session — the cwd fast-path covers the common case in between.
29
+ const FULL_SCAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
30
+ // Hard ceiling on the bulk POST. The hook is blocking session open; a slow or
31
+ // unreachable server must not stall it. Errors / timeouts just defer
32
+ // registration to a future session (the repo stays "unknown" in the cache).
33
+ const POST_TIMEOUT_MS = 4000;
34
+ /**
35
+ * Discover repos that aren't yet known to Replen and register their identity.
36
+ * Best-effort and silent: every failure path returns quietly. Designed to be
37
+ * awaited in parallel with the inventory fetch so it adds ~no wall-clock when
38
+ * there's nothing new (the common case: cwd repo already known, no scan due).
39
+ */
40
+ export async function autoRegisterNewRepos(opts) {
41
+ const known = await readKnownRepos();
42
+ const knownSet = new Set(known.repos.map((r) => r.toLowerCase()));
43
+ const scanDue = isFullScanDue(known.lastFullScanAt);
44
+ // Gather discovered projects, deduped by githubFullName across both triggers.
45
+ const byFullName = new Map();
46
+ // 1. cwd fast-path: walk just the repo the user opened (resolved to its
47
+ // git toplevel so subdir cwds still work).
48
+ const cwdRoot = repoRootForCwd();
49
+ if (cwdRoot) {
50
+ for (const p of safeDiscover([cwdRoot])) {
51
+ if (p.githubFullName)
52
+ byFullName.set(p.githubFullName, p);
53
+ }
54
+ }
55
+ // 2. throttled full walk.
56
+ if (scanDue) {
57
+ const roots = await resolveScanRoots();
58
+ if (roots.length > 0) {
59
+ for (const p of safeDiscover(roots)) {
60
+ if (p.githubFullName)
61
+ byFullName.set(p.githubFullName, p);
62
+ }
63
+ }
64
+ }
65
+ const fresh = Array.from(byFullName.values()).filter((p) => p.githubFullName && !knownSet.has(p.githubFullName.toLowerCase()));
66
+ if (fresh.length === 0) {
67
+ // Nothing new — but if a scan was due, record that it ran so we don't
68
+ // re-walk the filesystem every session.
69
+ if (scanDue)
70
+ await mergeKnownRepos([], true);
71
+ return;
72
+ }
73
+ const ok = await postProjectsBulk(opts.base, opts.token, fresh);
74
+ if (ok) {
75
+ // Cache the now-registered repos so we don't re-POST them next session.
76
+ await mergeKnownRepos(fresh.map((p) => p.githubFullName), scanDue);
77
+ }
78
+ else if (scanDue) {
79
+ // POST failed: don't mark these repos known (a later session retries
80
+ // them), but do stamp the scan time so we don't re-walk on every session
81
+ // while the server is unreachable. The cwd fast-path still retries.
82
+ await mergeKnownRepos([], true);
83
+ }
84
+ }
85
+ function isFullScanDue(lastFullScanAt) {
86
+ if (!lastFullScanAt)
87
+ return true;
88
+ const t = Date.parse(lastFullScanAt);
89
+ if (Number.isNaN(t))
90
+ return true;
91
+ return Date.now() - t > FULL_SCAN_INTERVAL_MS;
92
+ }
93
+ // Resolve the cwd to its git repo root so a subdir cwd still discovers the
94
+ // repo. Returns null when not in a git repo.
95
+ function repoRootForCwd() {
96
+ try {
97
+ const top = execSync("git rev-parse --show-toplevel", {
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ encoding: "utf8",
100
+ timeout: 1500,
101
+ }).trim();
102
+ return top || null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ // Non-interactive root resolution for the full walk. Mirrors sync-projects'
109
+ // inference order but NEVER prompts (the hook has no TTY contract) — config
110
+ // and env first, then the high-signal Claude-tracked dirs and conventional
111
+ // layout. discoverProjects dedups by path + githubFullName, so the union is
112
+ // safe even when roots overlap.
113
+ async function resolveScanRoots() {
114
+ const fromConfig = await rootsFromConfig();
115
+ return Array.from(new Set([
116
+ ...fromConfig,
117
+ ...rootsFromEnv(),
118
+ ...rootsFromClaudeJson(),
119
+ ...rootsFromHardcoded(),
120
+ ]));
121
+ }
122
+ // discoverProjects shells out to git per repo; guard the whole walk so a
123
+ // transient FS/git error can't bubble out of the hook.
124
+ function safeDiscover(roots) {
125
+ try {
126
+ return discoverProjects(roots).projects;
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ }
132
+ // Silent, bounded POST to the same bulk-upsert endpoint the manual sync uses.
133
+ // Returns whether the registration succeeded.
134
+ async function postProjectsBulk(base, token, projects) {
135
+ const ctrl = new AbortController();
136
+ const timer = setTimeout(() => ctrl.abort(), POST_TIMEOUT_MS);
137
+ try {
138
+ const res = await fetch(`${base}/api/projects/bulk`, {
139
+ method: "POST",
140
+ headers: {
141
+ "x-digest-token": token,
142
+ "content-type": "application/json",
143
+ },
144
+ body: JSON.stringify({
145
+ projects: projects.map((p) => ({
146
+ slug: p.slug,
147
+ githubFullName: p.githubFullName,
148
+ name: p.name,
149
+ tags: p.tags,
150
+ primaryLanguage: p.primaryLanguage ?? undefined,
151
+ })),
152
+ }),
153
+ signal: ctrl.signal,
154
+ });
155
+ return res.ok;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
package/dist/commands.js CHANGED
@@ -217,7 +217,16 @@ export async function runCheckNew(argv) {
217
217
  }
218
218
  return;
219
219
  }
220
- const inv = await fetchInventoryStatus(cfg, cwdRepo);
220
+ // Auto-register newly-appeared repos (identity only — owner/name +
221
+ // manifest tags, no code, no LLM) in parallel with the inventory fetch.
222
+ // Both are bounded and best-effort; neither may slow or disrupt session
223
+ // open. When there's nothing new (the common case) auto-register returns
224
+ // near-instantly, adding no wall-clock over the inventory call alone.
225
+ const { autoRegisterNewRepos } = await import("./auto-register.js");
226
+ const [, inv] = await Promise.all([
227
+ autoRegisterNewRepos({ token: cfg.token, base: cfg.base }).catch(() => undefined),
228
+ fetchInventoryStatus(cfg, cwdRepo),
229
+ ]);
221
230
  if (inv?.displayText) {
222
231
  console.log("[Replen] Relay the following line to the user, verbatim, as a footnote at the very end of your first reply:");
223
232
  console.log(inv.displayText);
@@ -0,0 +1,55 @@
1
+ // Local cache of repos Replen has already seen, plus the timestamp of the
2
+ // last full-portfolio scan. Lives next to the auth config in ~/.replen/.
3
+ //
4
+ // Purpose: the SessionStart hook (auto-register.ts) needs to answer "is this
5
+ // repo new?" without POSTing the user's whole portfolio to the server on
6
+ // every single session. We cache the set of githubFullNames we've registered
7
+ // and only send the diff. The server upsert is idempotent, so a stale cache
8
+ // is harmless (worst case: a redundant POST); the cache is a latency
9
+ // optimisation, not a source of truth.
10
+ //
11
+ // Identity only — this file never stores code, paths, or capabilities, just
12
+ // the owner/name strings already public on GitHub.
13
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join, dirname } from "node:path";
16
+ const KNOWN_FILE = join(homedir(), ".replen", "known-repos.json");
17
+ const EMPTY = { repos: [], lastFullScanAt: null };
18
+ export async function readKnownRepos() {
19
+ try {
20
+ const parsed = JSON.parse(await readFile(KNOWN_FILE, "utf8"));
21
+ return {
22
+ repos: Array.isArray(parsed.repos)
23
+ ? parsed.repos.filter((r) => typeof r === "string")
24
+ : [],
25
+ lastFullScanAt: typeof parsed.lastFullScanAt === "string" ? parsed.lastFullScanAt : null,
26
+ };
27
+ }
28
+ catch {
29
+ // Missing or malformed cache — treat as "nothing known yet".
30
+ return { ...EMPTY };
31
+ }
32
+ }
33
+ /**
34
+ * Merge newly-registered repos into the cache. When `didFullScan` is true,
35
+ * also stamp `lastFullScanAt` so the throttled full walk doesn't re-run for
36
+ * the configured interval. Best-effort: a write failure is swallowed (the
37
+ * cache only ever costs a redundant idempotent POST next session).
38
+ */
39
+ export async function mergeKnownRepos(newRepos, didFullScan) {
40
+ const cur = await readKnownRepos();
41
+ const set = new Set(cur.repos);
42
+ for (const r of newRepos)
43
+ set.add(r);
44
+ const next = {
45
+ repos: Array.from(set).sort(),
46
+ lastFullScanAt: didFullScan ? new Date().toISOString() : cur.lastFullScanAt,
47
+ };
48
+ try {
49
+ await mkdir(dirname(KNOWN_FILE), { recursive: true, mode: 0o700 });
50
+ await writeFile(KNOWN_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
51
+ }
52
+ catch {
53
+ // intentionally ignore — cache write must never disrupt a session
54
+ }
55
+ }
package/dist/mcp-setup.js CHANGED
@@ -26,6 +26,14 @@ import { fileURLToPath } from "node:url";
26
26
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
27
27
  import { installSkills } from "./skill-install.js";
28
28
  const SERVER_NAME = "replen";
29
+ // Launch the MCP via a SEMVER RANGE, not a tag-less spec. Bare `npx @replen/mcp`
30
+ // resolves once and reuses the cached build forever, so users silently run stale
31
+ // builds and miss new tools (the paths/mode regression came from exactly this).
32
+ // `@^1` makes npx re-resolve the newest 1.x on every session spawn — auto-updates
33
+ // minors/patches, never jumps a breaking major — so a fresh agent session = latest
34
+ // features with no manual `@latest` step to remember. Each setup run REWRITES this
35
+ // entry, so returning users are migrated off the old tag-less spec automatically.
36
+ const MCP_PKG = "@replen/mcp@^1";
29
37
  const CLAUDE_CONFIG = join(homedir(), ".claude.json");
30
38
  const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
31
39
  const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
@@ -99,7 +107,7 @@ function setupClaude(token, base) {
99
107
  mcpServers[SERVER_NAME] = {
100
108
  type: "stdio",
101
109
  command: "npx",
102
- args: ["-y", "@replen/mcp"],
110
+ args: ["-y", MCP_PKG],
103
111
  env: { DIGEST_BASE_URL: base, DIGEST_TOKEN: token },
104
112
  };
105
113
  const hooks = installSessionStartHook(config.hooks ?? {});
@@ -146,7 +154,7 @@ function setupGemini(token, base) {
146
154
  // docs/tools/mcp-server.md): command, args, env. No `type` field.
147
155
  mcpServers[SERVER_NAME] = {
148
156
  command: "npx",
149
- args: ["-y", "@replen/mcp"],
157
+ args: ["-y", MCP_PKG],
150
158
  env: { DIGEST_BASE_URL: base, DIGEST_TOKEN: token },
151
159
  };
152
160
  writeJsonAtomic(path, { ...config, mcpServers });
@@ -168,7 +176,7 @@ function setupCodex(token, base) {
168
176
  // handles automatically for small objects.
169
177
  mcpServers[SERVER_NAME] = {
170
178
  command: "npx",
171
- args: ["-y", "@replen/mcp"],
179
+ args: ["-y", MCP_PKG],
172
180
  env: { DIGEST_BASE_URL: base, DIGEST_TOKEN: token },
173
181
  };
174
182
  writeTomlAtomic(path, { ...config, mcp_servers: mcpServers });
@@ -239,6 +239,28 @@ A triage that isn't recorded never happened.
239
239
  Only the user-judgment actions (star / hide / handoff / queue work) wait
240
240
  for the user. Verdicts never do.
241
241
 
242
+ #### 3e. Write back what you LEARNED from the code — not just the verdict
243
+
244
+ Triage is the one moment you actually read the source, so feed what you
245
+ learned back into Replen's model of the project (it's non-destructive and
246
+ makes every future match sharper). Two cheap write-backs, both optional,
247
+ both additive:
248
+
249
+ - **Capability paths.** When you confirm which file(s) implement the
250
+ capability a candidate matched — especially if the candidate's
251
+ `matchedFacet` has no paths yet — call `replen_set_capabilities` with
252
+ **`mode:"merge"`** and just that one capability:
253
+ `{ tag: "<matchedFacet>", paths: ["src/…", "lib/…"] }`. Merge mode adds
254
+ the paths without touching the project's other capabilities. This is what
255
+ makes "port THIS exact file" possible in later sessions and populates the
256
+ Atlas dossier + cross-project leaps. Paths only, never code.
257
+ - **Dep corrections.** When the code shows a listed dependency is unused or
258
+ superseded (e.g. a stale `ethers` in a repo that's actually on `viem`),
259
+ pass `depsSuperseded: ["ethers"]` on the `replen_record_triage` call —
260
+ it's marked migrate-off (reversible) so its release/pricing/upgrade noise
261
+ stops. Pass `depsConfirmed: [...]` for deps you verified are genuinely
262
+ used that Replen didn't already know. Names only.
263
+
242
264
  No marketing voice. No hype. The user is a working engineer; talk to
243
265
  them like a peer. Concrete > clever.
244
266
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "Make your AI coding tools smarter. One command, no API keys, free. Replen watches what your projects actually do and surfaces a few things worth bringing in each month. Use one as is, port a piece of another, cherry pick an idea, or build it clean room. The match happens inside your AI tool's session. A few actionable matches a month, by design.",
5
5
  "type": "module",
6
6
  "bin": {