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.
- package/dist/auto-register.js +163 -0
- package/dist/commands.js +10 -1
- package/dist/known-repos.js +55 -0
- package/dist/mcp-setup.js +11 -3
- package/extras/skills/replen/SKILL.md +22 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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.
|
|
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": {
|