liteagents 2.24.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/README.md +27 -11
- package/installer/cli.js +36 -3
- package/installer/installation-engine.js +8 -0
- package/package.json +2 -2
- package/packages/ampcode/AGENT.md +1 -2
- package/packages/ampcode/agents/orchestrator.md +2 -2
- package/packages/ampcode/commands/refactor.md +8 -2
- package/packages/ampcode/commands/remember/stub-check.cjs +197 -0
- package/packages/ampcode/commands/remember/sync-rules.cjs +169 -0
- package/packages/ampcode/commands/remember/version-check.cjs +214 -0
- package/packages/ampcode/commands/remember.md +78 -10
- package/packages/claude/CLAUDE.md +1 -2
- package/packages/claude/agents/orchestrator.md +2 -2
- package/packages/claude/commands/refactor.md +8 -2
- package/packages/claude/commands/remember/stub-check.cjs +197 -0
- package/packages/claude/commands/remember/sync-rules.cjs +169 -0
- package/packages/claude/commands/remember/version-check.cjs +214 -0
- package/packages/claude/commands/remember.md +78 -10
- package/packages/droid/AGENTS.md +1 -2
- package/packages/droid/commands/refactor.md +8 -2
- package/packages/droid/commands/remember/stub-check.cjs +197 -0
- package/packages/droid/commands/remember/sync-rules.cjs +169 -0
- package/packages/droid/commands/remember/version-check.cjs +214 -0
- package/packages/droid/commands/remember.md +78 -10
- package/packages/droid/droids/orchestrator.md +2 -2
- package/packages/opencode/AGENTS.md +1 -2
- package/packages/opencode/agent/orchestrator.md +2 -2
- package/packages/opencode/command/refactor.md +8 -2
- package/packages/opencode/command/remember/stub-check.cjs +197 -0
- package/packages/opencode/command/remember/sync-rules.cjs +169 -0
- package/packages/opencode/command/remember/version-check.cjs +214 -0
- package/packages/opencode/command/remember.md +78 -10
- package/packages/opencode/opencode.jsonc +0 -10
- package/packages/subagentic-manual.md +15 -15
- package/packages/ampcode/agents/context-builder.md +0 -144
- package/packages/claude/agents/context-builder.md +0 -145
- package/packages/droid/droids/context-builder.md +0 -144
- package/packages/opencode/agent/context-builder.md +0 -148
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* stub-check.cjs — asserts the two managed blocks in the tool config carry the
|
|
6
|
+
* CURRENT stub shape, and repairs the shape when it is wrong.
|
|
7
|
+
*
|
|
8
|
+
* Shape, not content. The blocks' prose is user-owned — step 5 writes it once
|
|
9
|
+
* and never re-imposes it, because users trim it deliberately. What this script
|
|
10
|
+
* touches is only the mechanism:
|
|
11
|
+
*
|
|
12
|
+
* MEMORY block @<PROJECT_DIR>/remember/MEMORY.md — an @-include.
|
|
13
|
+
* A bare `@MEMORY.md` resolves relative to the file that
|
|
14
|
+
* contains it, so in a repo root it names a file that does
|
|
15
|
+
* not exist and hot memory silently never loads.
|
|
16
|
+
*
|
|
17
|
+
* AGENT_RULES block <PROJECT_DIR>/remember/AGENT_RULES.md — a PLAIN pointer.
|
|
18
|
+
* v2.19 demoted it from an @-include on purpose: it is a
|
|
19
|
+
* standards guide to consult when building something new,
|
|
20
|
+
* not hot context, and @-including it loads ~300 lines into
|
|
21
|
+
* every session. Measured 2026-09-03: 21 of 37 local repos
|
|
22
|
+
* still carried the pre-v2.19 @-include.
|
|
23
|
+
*
|
|
24
|
+
* Two deliberate limits:
|
|
25
|
+
*
|
|
26
|
+
* - It only edits INSIDE a marker pair. A pointer elsewhere in the config is
|
|
27
|
+
* the user's prose and is left alone.
|
|
28
|
+
* - It never repoints the MEMORY include at a file that does not exist. An
|
|
29
|
+
* un-migrated `.claude/memory/` repo has a live MEMORY.md at the old path;
|
|
30
|
+
* rewriting it to the new one would break a working include to satisfy a
|
|
31
|
+
* naming convention. That case is reported, not repaired.
|
|
32
|
+
*
|
|
33
|
+
* Missing marker pairs are not this script's business — step 5 creates them.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
// The ONE pair of lines that differs across packages.
|
|
40
|
+
const PROJECT_DIR = '.claude';
|
|
41
|
+
const CONFIG_FILE = 'CLAUDE.md';
|
|
42
|
+
|
|
43
|
+
const MEM = { start: '<!-- MEMORY:START -->', end: '<!-- MEMORY:END -->' };
|
|
44
|
+
const RULES = { start: '<!-- AGENT_RULES:START -->', end: '<!-- AGENT_RULES:END -->' };
|
|
45
|
+
|
|
46
|
+
const MEMORY_REL = `${PROJECT_DIR}/remember/MEMORY.md`;
|
|
47
|
+
const RULES_REL = `${PROJECT_DIR}/remember/AGENT_RULES.md`;
|
|
48
|
+
|
|
49
|
+
/** lstat, not existsSync: existsSync follows links, so a DANGLING link reads
|
|
50
|
+
* as absent and gets walked straight past. */
|
|
51
|
+
function lexists(p) {
|
|
52
|
+
try { fs.lstatSync(p); return true; } catch (e) { return false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True when writing to `target` would land outside `repo`.
|
|
57
|
+
*
|
|
58
|
+
* There are two ways out and a guard on only one of them is false safety:
|
|
59
|
+
* `target` may itself be a symlink — including a dangling one, which reads as
|
|
60
|
+
* "the file is absent" and is still followed on write — or any parent
|
|
61
|
+
* directory may be a link pointing elsewhere. This runs across a whole fleet
|
|
62
|
+
* of repos, so a relative link only has to reach a sibling checkout.
|
|
63
|
+
*/
|
|
64
|
+
function escapesRepo(repo, target) {
|
|
65
|
+
let root;
|
|
66
|
+
try { root = fs.realpathSync(repo); } catch (e) { return true; }
|
|
67
|
+
|
|
68
|
+
// Walk up to the deepest ancestor that exists; anything below it cannot be
|
|
69
|
+
// a link yet, so only the existing part needs resolving.
|
|
70
|
+
const tail = [];
|
|
71
|
+
let dir = path.dirname(target);
|
|
72
|
+
while (!lexists(dir)) {
|
|
73
|
+
tail.unshift(path.basename(dir));
|
|
74
|
+
const up = path.dirname(dir);
|
|
75
|
+
if (up === dir) return true; // walked off the filesystem root
|
|
76
|
+
dir = up;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let resolved;
|
|
80
|
+
try { resolved = path.join(fs.realpathSync(dir), ...tail, path.basename(target)); }
|
|
81
|
+
catch (e) { return true; } // an ancestor is a dangling link
|
|
82
|
+
|
|
83
|
+
// The last component can be a link even when every directory above it is
|
|
84
|
+
// clean — that is the dangling-file case.
|
|
85
|
+
try { if (fs.lstatSync(resolved).isSymbolicLink()) return true; } catch (e) { /* absent: fine */ }
|
|
86
|
+
|
|
87
|
+
return resolved !== root && !resolved.startsWith(root + path.sep);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Index range of the lines strictly between a marker pair, or null. */
|
|
91
|
+
function blockRange(lines, markers) {
|
|
92
|
+
const s = lines.findIndex((l) => l.trim() === markers.start);
|
|
93
|
+
if (s === -1) return null;
|
|
94
|
+
const e = lines.findIndex((l, i) => i > s && l.trim() === markers.end);
|
|
95
|
+
if (e === -1) return null;
|
|
96
|
+
return { from: s + 1, to: e }; // [from, to)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @returns {{fixes:string[], notes:string[], changed:boolean}}
|
|
101
|
+
* fixes — repairs written to disk
|
|
102
|
+
* notes — wrong shapes deliberately left alone, with the reason
|
|
103
|
+
*/
|
|
104
|
+
function check(repo) {
|
|
105
|
+
const config = path.join(repo, CONFIG_FILE);
|
|
106
|
+
const fixes = [];
|
|
107
|
+
const notes = [];
|
|
108
|
+
|
|
109
|
+
// Checked before the read, not just before the write: a repair decided
|
|
110
|
+
// from a followed link is already the wrong decision.
|
|
111
|
+
if (escapesRepo(repo, config)) {
|
|
112
|
+
return { fixes, notes: [`${CONFIG_FILE} not checked: it leaves the repo via a symlink`], changed: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let text;
|
|
116
|
+
try { text = fs.readFileSync(config, 'utf8'); } catch (e) {
|
|
117
|
+
return { fixes, notes, changed: false }; // no config: step 5 will create one
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = text.split('\n');
|
|
121
|
+
let changed = false;
|
|
122
|
+
|
|
123
|
+
// ── MEMORY block: must be an @-include naming the explicit path ────────────
|
|
124
|
+
const mem = blockRange(lines, MEM);
|
|
125
|
+
if (mem) {
|
|
126
|
+
const i = lines.findIndex(
|
|
127
|
+
(l, n) => n >= mem.from && n < mem.to && /^@\S*MEMORY\.md\s*$/.test(l.trim()));
|
|
128
|
+
if (i === -1) {
|
|
129
|
+
notes.push(`${CONFIG_FILE}: MEMORY block has no @-include — hot memory does not load`);
|
|
130
|
+
} else {
|
|
131
|
+
const want = `@${MEMORY_REL}`;
|
|
132
|
+
const have = lines[i].trim();
|
|
133
|
+
if (have !== want) {
|
|
134
|
+
if (fs.existsSync(path.join(repo, MEMORY_REL))) {
|
|
135
|
+
lines[i] = want;
|
|
136
|
+
changed = true;
|
|
137
|
+
fixes.push(`${CONFIG_FILE}: MEMORY include repaired, ${have} → ${want}`);
|
|
138
|
+
} else {
|
|
139
|
+
// The old path may be the only one with a file behind it.
|
|
140
|
+
notes.push(
|
|
141
|
+
`${CONFIG_FILE}: MEMORY include is ${have}, not ${want} — left as is, `
|
|
142
|
+
+ `${MEMORY_REL} does not exist yet`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── AGENT_RULES block: must be a PLAIN pointer, never an @-include ─────────
|
|
149
|
+
const rules = blockRange(lines, RULES);
|
|
150
|
+
if (rules) {
|
|
151
|
+
const i = lines.findIndex(
|
|
152
|
+
(l, n) => n >= rules.from && n < rules.to && /^@\S*AGENT_RULES\.md\s*$/.test(l.trim()));
|
|
153
|
+
if (i !== -1) {
|
|
154
|
+
// Demote in place. The path is kept as written — only the @ is dropped,
|
|
155
|
+
// because the @ is the defect and the path may be a deliberate variant.
|
|
156
|
+
const had = lines[i].trim();
|
|
157
|
+
lines[i] = had.slice(1);
|
|
158
|
+
changed = true;
|
|
159
|
+
fixes.push(
|
|
160
|
+
`${CONFIG_FILE}: AGENT_RULES pointer demoted from an @-include (${had} → ${had.slice(1)}) `
|
|
161
|
+
+ `— it is a standards guide, not hot context`);
|
|
162
|
+
} else {
|
|
163
|
+
const hasPointer = lines
|
|
164
|
+
.slice(rules.from, rules.to)
|
|
165
|
+
.some((l) => /AGENT_RULES\.md/.test(l));
|
|
166
|
+
if (!hasPointer) {
|
|
167
|
+
notes.push(`${CONFIG_FILE}: AGENT_RULES block has no path pointer — nothing points at the rules`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (changed) {
|
|
173
|
+
try {
|
|
174
|
+
fs.writeFileSync(config, lines.join('\n'));
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { fixes: [], notes: [`${CONFIG_FILE} not repaired: ${e.message}`], changed: false };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { fixes, notes, changed };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function main() {
|
|
184
|
+
const repo = process.argv[2] || process.cwd();
|
|
185
|
+
const r = check(repo);
|
|
186
|
+
for (const line of r.fixes) process.stdout.write(`${line}\n`);
|
|
187
|
+
for (const line of r.notes) process.stdout.write(`${line}\n`);
|
|
188
|
+
// Silent when the shape is already current — the common case.
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (require.main === module) {
|
|
192
|
+
// A passenger on /remember, like version-check.cjs and sync-rules.cjs: it
|
|
193
|
+
// never gets to fail the run it rides in.
|
|
194
|
+
try { main(); } catch (e) { /* silent */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { check, PROJECT_DIR, CONFIG_FILE, MEMORY_REL, RULES_REL };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* sync-rules.cjs — keeps a repo's AGENT_RULES.md current with the installed
|
|
6
|
+
* template, without ever destroying what was there.
|
|
7
|
+
*
|
|
8
|
+
* Run every /remember, from the target repo. Before this existed, the rules
|
|
9
|
+
* were bootstrapped once and never refreshed, so a measured 35 local repos
|
|
10
|
+
* drifted to a body many releases old and three hand sweeps failed to hold.
|
|
11
|
+
*
|
|
12
|
+
* Three outcomes, decided by a byte compare — not a stored hash, because the
|
|
13
|
+
* only question is "am I about to change this file?", which any careful copy
|
|
14
|
+
* asks anyway:
|
|
15
|
+
*
|
|
16
|
+
* absent copy it in and say so
|
|
17
|
+
* identical do nothing at all: no write, no backup, no output
|
|
18
|
+
* differs move the old body aside, copy the new one in, say so loudly
|
|
19
|
+
*
|
|
20
|
+
* THE BACKUP IS A SINGLE FILE and is overwritten on each differing run. A
|
|
21
|
+
* customised file therefore survives exactly one update: fold your changes
|
|
22
|
+
* into the new AGENT_RULES.md before the next release, or the next sync
|
|
23
|
+
* replaces the backup with a vanilla body. This is a deliberate trade — see
|
|
24
|
+
* docs/product/agent-rules-freshness-prd.md §5 — chosen over accumulating
|
|
25
|
+
* timestamped backups.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
// Per-kit PROJECT dir. NOTE this is not always the global config dir: amp
|
|
32
|
+
// installs to ~/.config/amp but writes .amp/ in a repo, and opencode likewise.
|
|
33
|
+
// This is the ONE line that differs across packages.
|
|
34
|
+
const PROJECT_DIR = '.claude';
|
|
35
|
+
|
|
36
|
+
const RULES = 'AGENT_RULES.md';
|
|
37
|
+
const BACKUP = 'AGENT_RULES.md.bak'; // keeps the origin name; not .md, so doc tooling ignores it
|
|
38
|
+
|
|
39
|
+
/** lstat, not existsSync: existsSync follows links, so a DANGLING link reads
|
|
40
|
+
* as absent and gets walked straight past. */
|
|
41
|
+
function lexists(p) {
|
|
42
|
+
try { fs.lstatSync(p); return true; } catch (e) { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when writing to `target` would land outside `repo`.
|
|
47
|
+
*
|
|
48
|
+
* There are two ways out and a guard on only one of them is false safety:
|
|
49
|
+
* `target` may itself be a symlink — including a dangling one, which reads as
|
|
50
|
+
* "the file is absent" and is still followed on write — or any parent
|
|
51
|
+
* directory may be a link pointing elsewhere. This runs across a whole fleet
|
|
52
|
+
* of repos, so a relative link only has to reach a sibling checkout.
|
|
53
|
+
*/
|
|
54
|
+
function escapesRepo(repo, target) {
|
|
55
|
+
let root;
|
|
56
|
+
try { root = fs.realpathSync(repo); } catch (e) { return true; }
|
|
57
|
+
|
|
58
|
+
// Walk up to the deepest ancestor that exists; anything below it cannot be
|
|
59
|
+
// a link yet, so only the existing part needs resolving.
|
|
60
|
+
const tail = [];
|
|
61
|
+
let dir = path.dirname(target);
|
|
62
|
+
while (!lexists(dir)) {
|
|
63
|
+
tail.unshift(path.basename(dir));
|
|
64
|
+
const up = path.dirname(dir);
|
|
65
|
+
if (up === dir) return true; // walked off the filesystem root
|
|
66
|
+
dir = up;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let resolved;
|
|
70
|
+
try { resolved = path.join(fs.realpathSync(dir), ...tail, path.basename(target)); }
|
|
71
|
+
catch (e) { return true; } // an ancestor is a dangling link
|
|
72
|
+
|
|
73
|
+
// The last component can be a link even when every directory above it is
|
|
74
|
+
// clean — that is the dangling-file case.
|
|
75
|
+
try { if (fs.lstatSync(resolved).isSymbolicLink()) return true; } catch (e) { /* absent: fine */ }
|
|
76
|
+
|
|
77
|
+
return resolved !== root && !resolved.startsWith(root + path.sep);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function templatePath() {
|
|
81
|
+
// Ships beside this script, so no path guessing and no dependence on where
|
|
82
|
+
// the kit was installed.
|
|
83
|
+
return path.join(__dirname, RULES);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function targetPath(repo) {
|
|
87
|
+
return path.join(repo, PROJECT_DIR, 'remember', RULES);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @returns {{action:string, detail?:string}} action is one of:
|
|
92
|
+
* 'no-template' | 'bootstrapped' | 'unchanged' | 'updated' | 'failed'
|
|
93
|
+
*/
|
|
94
|
+
function sync(repo) {
|
|
95
|
+
const tpl = templatePath();
|
|
96
|
+
let template;
|
|
97
|
+
try {
|
|
98
|
+
template = fs.readFileSync(tpl);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { action: 'no-template', detail: tpl };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const target = targetPath(repo);
|
|
104
|
+
if (escapesRepo(repo, target)) return { action: 'escapes', detail: target };
|
|
105
|
+
|
|
106
|
+
let current = null;
|
|
107
|
+
try { current = fs.readFileSync(target); } catch (e) { /* absent */ }
|
|
108
|
+
|
|
109
|
+
// Byte compare. No normalisation on either side: the copy below is a plain
|
|
110
|
+
// byte write, so a mismatch here means the content really differs.
|
|
111
|
+
if (current && current.equals(template)) return { action: 'unchanged' };
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
115
|
+
if (current) {
|
|
116
|
+
// Single backup, overwritten. rename() is atomic on the same filesystem
|
|
117
|
+
// and cannot leave a half-written backup the way copy+truncate could.
|
|
118
|
+
fs.renameSync(target, path.join(path.dirname(target), BACKUP));
|
|
119
|
+
}
|
|
120
|
+
fs.writeFileSync(target, template);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { action: 'failed', detail: e.message };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return current ? { action: 'updated' } : { action: 'bootstrapped' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function main() {
|
|
129
|
+
const repo = process.argv[2] || process.cwd();
|
|
130
|
+
const r = sync(repo);
|
|
131
|
+
const rel = path.join(PROJECT_DIR, 'remember', RULES);
|
|
132
|
+
|
|
133
|
+
switch (r.action) {
|
|
134
|
+
case 'unchanged':
|
|
135
|
+
break; // silent: nothing happened
|
|
136
|
+
case 'bootstrapped':
|
|
137
|
+
process.stdout.write(`${rel} created from the installed template\n`);
|
|
138
|
+
break;
|
|
139
|
+
case 'updated':
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`${rel} updated from the installed template `
|
|
142
|
+
+ `(previous body kept as ${BACKUP} — fold your changes in before the `
|
|
143
|
+
+ `next release, it is a single file and the next update replaces it)\n`);
|
|
144
|
+
break;
|
|
145
|
+
case 'escapes':
|
|
146
|
+
// Loud, never repaired: the path is under the repo but does not stay
|
|
147
|
+
// there, so any write lands somewhere the run was not invited.
|
|
148
|
+
process.stdout.write(
|
|
149
|
+
`${rel} not synced: it leaves the repo via a symlink — refusing to `
|
|
150
|
+
+ `write through it\n`);
|
|
151
|
+
break;
|
|
152
|
+
case 'no-template':
|
|
153
|
+
// Loud: this means the install is incomplete, not that nothing changed.
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
`AGENT_RULES.md not synced: no template beside this script (${r.detail})\n`);
|
|
156
|
+
break;
|
|
157
|
+
case 'failed':
|
|
158
|
+
process.stdout.write(`AGENT_RULES.md not synced: ${r.detail}\n`);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (require.main === module) {
|
|
164
|
+
// A passenger on /remember, like version-check.cjs: it never gets to fail
|
|
165
|
+
// the run it rides in.
|
|
166
|
+
try { main(); } catch (e) { /* silent */ }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { sync, PROJECT_DIR, RULES, BACKUP };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* version-check.cjs — tells the user their liteagents install is behind the
|
|
6
|
+
* registry, and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* Run as step 0 of /remember, alongside friction.cjs. It rides along with a
|
|
9
|
+
* memory consolidation run, so the only hard requirement is that it can never
|
|
10
|
+
* cost that run anything: it exits 0 on every path, prints at most one line,
|
|
11
|
+
* writes nothing but its own cache, and is bounded in wall-clock time.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT part of friction.cjs: that file mines friction signals, and
|
|
14
|
+
* a registry lookup is an unrelated concern.
|
|
15
|
+
*
|
|
16
|
+
* A POC (2026-09-03) found the one non-obvious defect guarded here:
|
|
17
|
+
* req.setTimeout is a socket-INACTIVITY timeout and does not bound connect
|
|
18
|
+
* time. Against an unroutable host a 2000ms budget overran to 5146ms. Only an
|
|
19
|
+
* explicit deadline bounds the total, so both are set.
|
|
20
|
+
*
|
|
21
|
+
* Environment:
|
|
22
|
+
* npm_config_registry registry base (npm sets this; mirrors work)
|
|
23
|
+
* LITEAGENTS_INSTALLED_VERSION skip local version discovery
|
|
24
|
+
* LITEAGENTS_SKIP_NPM_LOOKUP skip the `npm ls -g` fallback (it is slow)
|
|
25
|
+
*
|
|
26
|
+
* Installed version is resolved in cost order: the installer's manifest stamp
|
|
27
|
+
* (a file read), then our own package.json when run from a checkout, then
|
|
28
|
+
* `npm ls -g` as a last resort. The last one costs ~500ms on EVERY run, which
|
|
29
|
+
* is why the installer stamps the manifest at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const PKG = 'liteagents';
|
|
37
|
+
// Per-kit config dir. This is the ONE line that differs across packages.
|
|
38
|
+
const CONFIG_DIR = '.claude';
|
|
39
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
const DEADLINE_MS = 2000;
|
|
41
|
+
const NPM_LOOKUP_MS = 3000;
|
|
42
|
+
|
|
43
|
+
// --- version comparison --------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Is `b` a newer release than `a`? Numeric per component, so 2.9.0 < 2.10.0 —
|
|
47
|
+
* a string compare gets that backwards. A prerelease suffix is stripped, so
|
|
48
|
+
* 2.24.2-beta.1 counts as newer than 2.24.1: it is still a later release, and
|
|
49
|
+
* a user on it does not need advice about 2.24.1.
|
|
50
|
+
*/
|
|
51
|
+
function isNewer(a, b) {
|
|
52
|
+
const parse = (v) => String(v).trim().replace(/^v/, '').split('-')[0]
|
|
53
|
+
.split('.').map((n) => parseInt(n, 10) || 0);
|
|
54
|
+
const [x, y] = [parse(a), parse(b)];
|
|
55
|
+
for (let i = 0; i < 3; i++) {
|
|
56
|
+
const d = (y[i] || 0) - (x[i] || 0);
|
|
57
|
+
if (d !== 0) return d > 0;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- installed version ---------------------------------------------------
|
|
63
|
+
|
|
64
|
+
// Walk up from this file looking for our own package.json. Finds it when
|
|
65
|
+
// running from a checkout; finds nothing when installed into ~/.claude, which
|
|
66
|
+
// is why the npm fallback exists.
|
|
67
|
+
function versionFromPackageJson() {
|
|
68
|
+
let dir = __dirname;
|
|
69
|
+
for (let i = 0; i < 8; i++) {
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
72
|
+
if (pkg && pkg.name === PKG && pkg.version) return String(pkg.version);
|
|
73
|
+
} catch (e) { /* keep walking */ }
|
|
74
|
+
const up = path.dirname(dir);
|
|
75
|
+
if (up === dir) break;
|
|
76
|
+
dir = up;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The installer stamps the release it wrote into <install root>/manifest.json.
|
|
82
|
+
// version-check.cjs sits at <root>/<commands|command>/remember/, so the root is
|
|
83
|
+
// two levels up. This is the fast path: reading a file beats spawning npm.
|
|
84
|
+
function versionFromManifest() {
|
|
85
|
+
try {
|
|
86
|
+
const m = JSON.parse(fs.readFileSync(
|
|
87
|
+
path.join(__dirname, '..', '..', 'manifest.json'), 'utf8'));
|
|
88
|
+
// NOT m.version -- that is the manifest schema version, a different thing.
|
|
89
|
+
return m && m.liteagents_version ? String(m.liteagents_version) : null;
|
|
90
|
+
} catch (e) { return null; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function versionFromNpm() {
|
|
94
|
+
if (process.env.LITEAGENTS_SKIP_NPM_LOOKUP) return null;
|
|
95
|
+
try {
|
|
96
|
+
const r = require('child_process').spawnSync(
|
|
97
|
+
'npm', ['ls', '-g', PKG, '--depth=0', '--json'],
|
|
98
|
+
{ encoding: 'utf8', timeout: NPM_LOOKUP_MS }
|
|
99
|
+
);
|
|
100
|
+
if (!r.stdout) return null;
|
|
101
|
+
const deps = JSON.parse(r.stdout).dependencies || {};
|
|
102
|
+
return deps[PKG] && deps[PKG].version ? String(deps[PKG].version) : null;
|
|
103
|
+
} catch (e) { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installedVersion() {
|
|
107
|
+
const env = (process.env.LITEAGENTS_INSTALLED_VERSION || '').trim();
|
|
108
|
+
if (env) return env;
|
|
109
|
+
return versionFromManifest() || versionFromPackageJson() || versionFromNpm();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- cache ---------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// Home-scoped, not per-repo: it describes the global install, so a per-repo
|
|
115
|
+
// cache would make every repo fetch the same answer.
|
|
116
|
+
function cachePath() {
|
|
117
|
+
return path.join(os.homedir(), CONFIG_DIR, `.${PKG}-version.json`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readCache() {
|
|
121
|
+
try {
|
|
122
|
+
const c = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
|
|
123
|
+
if (typeof c.checked_at !== 'number' || typeof c.latest !== 'string') return null;
|
|
124
|
+
if (Date.now() - c.checked_at > TTL_MS) return null;
|
|
125
|
+
return c.latest;
|
|
126
|
+
} catch (e) { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Best effort. An unwritable cache dir means we re-fetch next run, never that
|
|
130
|
+
// we withhold the advice we already have.
|
|
131
|
+
function writeCache(latest) {
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
|
|
134
|
+
fs.writeFileSync(cachePath(), JSON.stringify({ checked_at: Date.now(), latest }));
|
|
135
|
+
} catch (e) { /* not worth a word */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- registry ------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function fetchLatest(cb) {
|
|
141
|
+
let base = process.env.npm_config_registry || 'https://registry.npmjs.org/';
|
|
142
|
+
if (!/\/$/.test(base)) base += '/';
|
|
143
|
+
const url = `${base}${PKG}/latest`;
|
|
144
|
+
|
|
145
|
+
let mod;
|
|
146
|
+
try { mod = url.startsWith('http://') ? require('http') : require('https'); }
|
|
147
|
+
catch (e) { return cb(null); }
|
|
148
|
+
|
|
149
|
+
let settled = false;
|
|
150
|
+
let req = null;
|
|
151
|
+
const finish = (v) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(deadline);
|
|
155
|
+
if (v === null && req) { try { req.destroy(); } catch (e) { /* */ } }
|
|
156
|
+
cb(v);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// The hard bound. req.setTimeout below does not cover connect time.
|
|
160
|
+
const deadline = setTimeout(() => finish(null), DEADLINE_MS);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
req = mod.get(url, { headers: { accept: 'application/json' } }, (res) => {
|
|
164
|
+
if (res.statusCode !== 200) { res.resume(); return finish(null); }
|
|
165
|
+
let body = '';
|
|
166
|
+
res.setEncoding('utf8');
|
|
167
|
+
res.on('data', (c) => {
|
|
168
|
+
body += c;
|
|
169
|
+
if (body.length > 1e6) finish(null); // a packument this big is not ours
|
|
170
|
+
});
|
|
171
|
+
res.on('end', () => {
|
|
172
|
+
try {
|
|
173
|
+
const v = JSON.parse(body).version;
|
|
174
|
+
finish(typeof v === 'string' && v ? v : null);
|
|
175
|
+
} catch (e) { finish(null); }
|
|
176
|
+
});
|
|
177
|
+
res.on('error', () => finish(null));
|
|
178
|
+
});
|
|
179
|
+
req.setTimeout(DEADLINE_MS, () => finish(null));
|
|
180
|
+
req.on('error', () => finish(null));
|
|
181
|
+
} catch (e) { finish(null); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- main ----------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function advise(installed, latest) {
|
|
187
|
+
if (!installed || !latest) return; // never guess
|
|
188
|
+
if (!isNewer(installed, latest)) return; // current, or ahead of the registry
|
|
189
|
+
process.stdout.write(
|
|
190
|
+
`liteagents ${installed} -> ${latest} available: `
|
|
191
|
+
+ `npm i -g ${PKG}@latest && ${PKG}\n`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function main() {
|
|
196
|
+
const installed = installedVersion();
|
|
197
|
+
|
|
198
|
+
const cached = readCache();
|
|
199
|
+
if (cached) return advise(installed, cached);
|
|
200
|
+
|
|
201
|
+
fetchLatest((latest) => {
|
|
202
|
+
if (!latest) return; // offline, slow, or broken: silent
|
|
203
|
+
writeCache(latest);
|
|
204
|
+
advise(installed, latest);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (require.main === module) {
|
|
209
|
+
// Every failure is silent by contract. This command is a passenger; it does
|
|
210
|
+
// not get to fail the run it is riding in.
|
|
211
|
+
try { main(); } catch (e) { /* silent */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { isNewer };
|