@lumoai/cli 1.39.0 → 1.40.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/assets/skill/SKILL.md +8 -3
- package/assets/skill/references/memory.md +78 -0
- package/assets/skill/references/sessions.md +45 -87
- package/assets/skill/references/verify.md +20 -0
- package/dist/cli/src/commands/memory-push.js +93 -0
- package/dist/cli/src/commands/memory-sync.js +104 -0
- package/dist/cli/src/index.js +18 -8
- package/dist/cli/src/lib/anchor-staleness.js +116 -0
- package/dist/cli/src/lib/apply-sync.js +57 -0
- package/dist/cli/src/lib/claude-memory-dir.js +20 -0
- package/dist/cli/src/lib/local-memory-store.js +85 -0
- package/dist/cli/src/lib/managed-block.js +33 -0
- package/dist/cli/src/lib/memory-content.js +50 -20
- package/dist/cli/src/lib/memory-reconcile.js +33 -0
- package/dist/cli/src/lib/upsync.js +50 -0
- package/dist/shared/src/code-anchor.js +92 -0
- package/dist/shared/src/index.js +5 -1
- package/package.json +1 -1
- package/dist/cli/src/commands/session-wrap.js +0 -48
- package/dist/cli/src/commands/wrap/blocked-prompt-section.js +0 -64
- package/dist/cli/src/commands/wrap/crossings-reminder.js +0 -49
- package/dist/cli/src/commands/wrap/fragment-usage-section.js +0 -66
- package/dist/cli/src/commands/wrap/memory-review-section.js +0 -81
- package/dist/cli/src/lib/failure-summary-api.js +0 -43
- package/dist/cli/src/lib/fragment-usage-api.js +0 -47
- package/dist/cli/src/lib/session-memory-api.js +0 -47
- package/dist/cli/src/lib/wrap-panel.js +0 -15
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRepoProbe = createRepoProbe;
|
|
4
|
+
exports.detectStaleCandidates = detectStaleCandidates;
|
|
5
|
+
exports.reportStaleCandidates = reportStaleCandidates;
|
|
6
|
+
exports.shouldRunAnchorCheck = shouldRunAnchorCheck;
|
|
7
|
+
exports.runAnchorCheck = runAnchorCheck;
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const code_anchor_1 = require("../../../shared/src/code-anchor");
|
|
10
|
+
/**
|
|
11
|
+
* Build an existence probe over the repo at `cwd`. Resolves against git-tracked
|
|
12
|
+
* state, never the dirty working tree, so an uncommitted local rename/branch
|
|
13
|
+
* cannot false-flag a team memory. Fails OPEN: if git is unavailable or the
|
|
14
|
+
* lookup errors, anchors are treated as still-existing (宁可放过 — never
|
|
15
|
+
* false-flag a good memory off a broken probe).
|
|
16
|
+
*/
|
|
17
|
+
function createRepoProbe(cwd) {
|
|
18
|
+
const tracked = new Set();
|
|
19
|
+
try {
|
|
20
|
+
const out = (0, node_child_process_1.execFileSync)('git', ['ls-files'], {
|
|
21
|
+
cwd,
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
for (const line of out.split('\n')) {
|
|
26
|
+
const p = line.trim();
|
|
27
|
+
if (p)
|
|
28
|
+
tracked.add(p);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// not a git repo / git missing — leave `tracked` empty → fail open below.
|
|
33
|
+
}
|
|
34
|
+
const ready = tracked.size > 0;
|
|
35
|
+
return {
|
|
36
|
+
fileExists: p => (ready ? tracked.has(p) : true),
|
|
37
|
+
symbolExists: name => {
|
|
38
|
+
if (!ready)
|
|
39
|
+
return true;
|
|
40
|
+
try {
|
|
41
|
+
// exit 0 = found. `-w` word-boundary, `-q` quiet, `-F` fixed string,
|
|
42
|
+
// `-e` so a name starting with '-' is never read as a flag.
|
|
43
|
+
(0, node_child_process_1.execFileSync)('git', ['grep', '-wqF', '-e', name], {
|
|
44
|
+
cwd,
|
|
45
|
+
stdio: 'ignore',
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// exit 1 = clean "no match" → absent; any other exit = probe error →
|
|
51
|
+
// fail open (treat as present) so we never false-flag on a git hiccup.
|
|
52
|
+
const status = err.status;
|
|
53
|
+
return status === 1 ? false : true;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Classify each synced memory and keep only the stale candidates (every anchor
|
|
60
|
+
* dead). Pure given the probe — the heavy lifting lives in
|
|
61
|
+
* {@link classifyMemoryStaleness}.
|
|
62
|
+
*/
|
|
63
|
+
function detectStaleCandidates(entries, probe) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const verdict = (0, code_anchor_1.classifyMemoryStaleness)(e.body, probe);
|
|
67
|
+
if (verdict.stale) {
|
|
68
|
+
out.push({ memoryId: e.id, deadAnchors: verdict.deadAnchors });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Report stale candidates to the server's stale-anchors endpoint (bearer auth).
|
|
75
|
+
* No candidates → no network call. This is the ONLY server write the detection
|
|
76
|
+
* path makes; it records candidacy, never archives.
|
|
77
|
+
*/
|
|
78
|
+
async function reportStaleCandidates(base, token, projectId, candidates) {
|
|
79
|
+
if (candidates.length === 0)
|
|
80
|
+
return { ok: true, recorded: 0 };
|
|
81
|
+
const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/memories/stale-anchors`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({ candidates }),
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
return { ok: false, status: res.status };
|
|
91
|
+
const data = (await res.json().catch(() => ({})));
|
|
92
|
+
return { ok: true, recorded: data.recorded };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether `memory sync` should run the anchor check. Skipped for an explicit
|
|
96
|
+
* `--no-anchor-check`, and for `--dry-run` / `--clean` (a preview or rollback
|
|
97
|
+
* must not write candidates to the server).
|
|
98
|
+
*/
|
|
99
|
+
function shouldRunAnchorCheck(options) {
|
|
100
|
+
if (options.anchorCheck === false)
|
|
101
|
+
return false;
|
|
102
|
+
if (options.dryRun || options.clean)
|
|
103
|
+
return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Orchestrate the check: build the repo probe, detect candidates over the synced
|
|
108
|
+
* entries, and report them. Best-effort — returns the candidate count and the
|
|
109
|
+
* report result; callers log and move on (never fail the sync over detection).
|
|
110
|
+
*/
|
|
111
|
+
async function runAnchorCheck(input) {
|
|
112
|
+
const probe = createRepoProbe(input.cwd);
|
|
113
|
+
const candidates = detectStaleCandidates(input.entries, probe);
|
|
114
|
+
const report = await reportStaleCandidates(input.base, input.token, input.projectId, candidates);
|
|
115
|
+
return { candidates, report };
|
|
116
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applySync = applySync;
|
|
4
|
+
const local_memory_store_1 = require("./local-memory-store");
|
|
5
|
+
const memory_reconcile_1 = require("./memory-reconcile");
|
|
6
|
+
const managed_block_1 = require("./managed-block");
|
|
7
|
+
/**
|
|
8
|
+
* Apply a downsync bundle to the local memory dir. Owns only `root/team/*.md`
|
|
9
|
+
* and the MEMORY.md managed block; dev-authored files and out-of-block index
|
|
10
|
+
* content are never touched. `--clean` removes all owned files + the block;
|
|
11
|
+
* `--dry-run` computes the plan and writes nothing.
|
|
12
|
+
*/
|
|
13
|
+
function applySync(root, bundle, opts = {}) {
|
|
14
|
+
if (opts.clean) {
|
|
15
|
+
const owned = (0, local_memory_store_1.readOwned)(root);
|
|
16
|
+
if (!opts.dryRun) {
|
|
17
|
+
for (const o of owned)
|
|
18
|
+
(0, local_memory_store_1.removeEntry)(root, o.id);
|
|
19
|
+
(0, local_memory_store_1.writeIndex)(root, (0, managed_block_1.removeBlock)((0, local_memory_store_1.readIndex)(root)));
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
added: [],
|
|
23
|
+
updated: [],
|
|
24
|
+
removed: owned.map(o => o.id),
|
|
25
|
+
skippedDrift: [],
|
|
26
|
+
noop: owned.length === 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const owned = (0, local_memory_store_1.readOwned)(root);
|
|
30
|
+
const plan = (0, memory_reconcile_1.diffSync)(owned, bundle.entries.map(e => ({ id: e.id, contentHash: e.contentHash })));
|
|
31
|
+
const byId = new Map(bundle.entries.map(e => [e.id, e]));
|
|
32
|
+
const summary = {
|
|
33
|
+
added: plan.add.map(a => a.id),
|
|
34
|
+
updated: plan.update.map(u => u.id),
|
|
35
|
+
removed: plan.remove,
|
|
36
|
+
skippedDrift: plan.skipDrift,
|
|
37
|
+
noop: plan.add.length === 0 &&
|
|
38
|
+
plan.update.length === 0 &&
|
|
39
|
+
plan.remove.length === 0,
|
|
40
|
+
};
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
return summary;
|
|
43
|
+
for (const a of plan.add) {
|
|
44
|
+
const e = byId.get(a.id);
|
|
45
|
+
(0, local_memory_store_1.writeEntry)(root, e);
|
|
46
|
+
}
|
|
47
|
+
for (const u of plan.update) {
|
|
48
|
+
const e = byId.get(u.id);
|
|
49
|
+
(0, local_memory_store_1.writeEntry)(root, e);
|
|
50
|
+
}
|
|
51
|
+
for (const id of plan.remove)
|
|
52
|
+
(0, local_memory_store_1.removeEntry)(root, id);
|
|
53
|
+
// Index = every bundle entry's line (added/updated/unchanged/drift-skipped all
|
|
54
|
+
// still exist locally); removed ids are absent from the bundle so they drop out.
|
|
55
|
+
(0, local_memory_store_1.writeIndex)(root, (0, managed_block_1.upsertBlock)((0, local_memory_store_1.readIndex)(root), bundle.entries.map(e => e.indexLine)));
|
|
56
|
+
return summary;
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveMemoryDir = resolveMemoryDir;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the local Claude Code memory directory for a project working dir.
|
|
8
|
+
* Claude Code stores per-project memory under
|
|
9
|
+
* `~/.claude/projects/<encoded-cwd>/memory/`, where the cwd is encoded by
|
|
10
|
+
* replacing path separators with `-` (e.g. `/Users/x/lumo` →
|
|
11
|
+
* `-Users-x-lumo`). The encoding lives ONLY here so a Claude Code change is a
|
|
12
|
+
* one-line fix; `lumo memory sync --dry-run` prints the resolved path so the
|
|
13
|
+
* dev can sanity-check it, and `--dir` overrides entirely.
|
|
14
|
+
*/
|
|
15
|
+
function resolveMemoryDir(cwd, home = (0, node_os_1.homedir)(), override) {
|
|
16
|
+
if (override)
|
|
17
|
+
return override;
|
|
18
|
+
const encoded = cwd.replace(/\//g, '-');
|
|
19
|
+
return (0, node_path_1.join)(home, '.claude', 'projects', encoded, 'memory');
|
|
20
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hashBody = hashBody;
|
|
4
|
+
exports.readOwned = readOwned;
|
|
5
|
+
exports.writeEntry = writeEntry;
|
|
6
|
+
exports.removeEntry = removeEntry;
|
|
7
|
+
exports.readIndex = readIndex;
|
|
8
|
+
exports.writeIndex = writeIndex;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const TEAM_DIR = 'team';
|
|
13
|
+
const INDEX_FILE = 'MEMORY.md';
|
|
14
|
+
function teamPath(root) {
|
|
15
|
+
return (0, node_path_1.join)(root, TEAM_DIR);
|
|
16
|
+
}
|
|
17
|
+
function hashBody(body) {
|
|
18
|
+
return (0, node_crypto_1.createHash)('sha256').update(body).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse the `lumo: { ... }` ownership marker. Returns null unless the file
|
|
22
|
+
* carries `source: team` — dev-authored files (no marker) are NOT owned and
|
|
23
|
+
* must never be touched by sync.
|
|
24
|
+
*/
|
|
25
|
+
function parseMarker(text) {
|
|
26
|
+
const m = text.match(/lumo:\s*\{([^}]*)\}/);
|
|
27
|
+
if (!m || !m[1])
|
|
28
|
+
return null;
|
|
29
|
+
const inner = m[1];
|
|
30
|
+
if (!/\bsource:\s*team\b/.test(inner))
|
|
31
|
+
return null;
|
|
32
|
+
const id = inner.match(/\bid:\s*([^,}\s]+)/)?.[1];
|
|
33
|
+
const contentHash = inner.match(/\bcontentHash:\s*([^,}\s]+)/)?.[1];
|
|
34
|
+
if (!id || !contentHash)
|
|
35
|
+
return null;
|
|
36
|
+
return { id, contentHash };
|
|
37
|
+
}
|
|
38
|
+
/** Body = everything after the closing frontmatter fence, minus the single
|
|
39
|
+
* trailing newline {@link writeEntry} appends (keeps write/read hashing symmetric). */
|
|
40
|
+
function bodyOf(text) {
|
|
41
|
+
const end = text.indexOf('\n---\n');
|
|
42
|
+
const raw = end === -1 ? text : text.slice(end + 5);
|
|
43
|
+
return raw.replace(/\n$/, '');
|
|
44
|
+
}
|
|
45
|
+
/** Read every owned team file under `root/team/`, newest-id order. */
|
|
46
|
+
function readOwned(root) {
|
|
47
|
+
const dir = teamPath(root);
|
|
48
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
49
|
+
return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const f of (0, node_fs_1.readdirSync)(dir)) {
|
|
52
|
+
if (!f.endsWith('.md'))
|
|
53
|
+
continue;
|
|
54
|
+
const text = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, f), 'utf8');
|
|
55
|
+
const marker = parseMarker(text);
|
|
56
|
+
if (!marker)
|
|
57
|
+
continue; // dev-authored → not owned
|
|
58
|
+
out.push({
|
|
59
|
+
id: marker.id,
|
|
60
|
+
contentHash: marker.contentHash,
|
|
61
|
+
diskHash: hashBody(bodyOf(text)),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
65
|
+
}
|
|
66
|
+
function writeEntry(root, entry) {
|
|
67
|
+
const dir = teamPath(root);
|
|
68
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
69
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
70
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${entry.id}.md`), `${entry.frontmatter}\n${entry.body}\n`);
|
|
71
|
+
}
|
|
72
|
+
function removeEntry(root, id) {
|
|
73
|
+
const p = (0, node_path_1.join)(teamPath(root), `${id}.md`);
|
|
74
|
+
if ((0, node_fs_1.existsSync)(p))
|
|
75
|
+
(0, node_fs_1.rmSync)(p);
|
|
76
|
+
}
|
|
77
|
+
function readIndex(root) {
|
|
78
|
+
const p = (0, node_path_1.join)(root, INDEX_FILE);
|
|
79
|
+
return (0, node_fs_1.existsSync)(p) ? (0, node_fs_1.readFileSync)(p, 'utf8') : '';
|
|
80
|
+
}
|
|
81
|
+
function writeIndex(root, text) {
|
|
82
|
+
if (!(0, node_fs_1.existsSync)(root))
|
|
83
|
+
(0, node_fs_1.mkdirSync)(root, { recursive: true });
|
|
84
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(root, INDEX_FILE), text);
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BLOCK_END = exports.BLOCK_START = void 0;
|
|
4
|
+
exports.upsertBlock = upsertBlock;
|
|
5
|
+
exports.removeBlock = removeBlock;
|
|
6
|
+
exports.BLOCK_START = '<!-- lumo:team-memory:start (managed by `lumo memory sync` — do not edit inside) -->';
|
|
7
|
+
exports.BLOCK_END = '<!-- lumo:team-memory:end -->';
|
|
8
|
+
/**
|
|
9
|
+
* Replace the managed team-memory block in MEMORY.md with `lines`, leaving
|
|
10
|
+
* every byte OUTSIDE the markers untouched. If no block exists, append a fresh
|
|
11
|
+
* one at EOF (original content preserved verbatim as the prefix). This is the
|
|
12
|
+
* only writer allowed to touch MEMORY.md, and it never edits dev-authored
|
|
13
|
+
* index lines outside the markers.
|
|
14
|
+
*/
|
|
15
|
+
function upsertBlock(md, lines) {
|
|
16
|
+
const block = `${exports.BLOCK_START}\n${lines.join('\n')}\n${exports.BLOCK_END}`;
|
|
17
|
+
const s = md.indexOf(exports.BLOCK_START);
|
|
18
|
+
const e = md.indexOf(exports.BLOCK_END);
|
|
19
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
20
|
+
return md.slice(0, s) + block + md.slice(e + exports.BLOCK_END.length);
|
|
21
|
+
}
|
|
22
|
+
const sep = md.length === 0 ? '' : md.endsWith('\n') ? '\n' : '\n\n';
|
|
23
|
+
return `${md}${sep}${block}\n`;
|
|
24
|
+
}
|
|
25
|
+
/** Remove the managed block entirely (used by `--clean`). Bytes outside the
|
|
26
|
+
* markers are preserved. No-op if no block is present. */
|
|
27
|
+
function removeBlock(md) {
|
|
28
|
+
const s = md.indexOf(exports.BLOCK_START);
|
|
29
|
+
const e = md.indexOf(exports.BLOCK_END);
|
|
30
|
+
if (s === -1 || e === -1 || e < s)
|
|
31
|
+
return md;
|
|
32
|
+
return md.slice(0, s) + md.slice(e + exports.BLOCK_END.length);
|
|
33
|
+
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildMemoryContent = buildMemoryContent;
|
|
4
4
|
exports.formatMemoryList = formatMemoryList;
|
|
5
|
-
exports.formatMemoryReviewList = formatMemoryReviewList;
|
|
6
5
|
// Category/field metadata + builders for the `lumo memory` commands.
|
|
7
6
|
// Mirrors the four content shapes validated server-side by parseMemoryContent.
|
|
8
7
|
const sanitize_1 = require("./sanitize");
|
|
@@ -24,7 +23,11 @@ function buildMemoryContent(category, flags) {
|
|
|
24
23
|
return { ok: false, error: '--trigger is required for category trap.' };
|
|
25
24
|
if (!outcome)
|
|
26
25
|
return { ok: false, error: '--outcome is required for category trap.' };
|
|
27
|
-
return {
|
|
26
|
+
return {
|
|
27
|
+
ok: true,
|
|
28
|
+
category: 'TRAP',
|
|
29
|
+
content: { trigger, outcome, ...(workaround ? { workaround } : {}) },
|
|
30
|
+
};
|
|
28
31
|
}
|
|
29
32
|
case 'decision': {
|
|
30
33
|
const what = trimmed(flags.what);
|
|
@@ -38,40 +41,73 @@ function buildMemoryContent(category, flags) {
|
|
|
38
41
|
return {
|
|
39
42
|
ok: true,
|
|
40
43
|
category: 'DECISION',
|
|
41
|
-
content: {
|
|
44
|
+
content: {
|
|
45
|
+
what,
|
|
46
|
+
why,
|
|
47
|
+
...(alternatives ? { alternatives } : {}),
|
|
48
|
+
...(implications ? { implications } : {}),
|
|
49
|
+
},
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
52
|
case 'convention': {
|
|
45
53
|
const rule = trimmed(flags.rule);
|
|
46
54
|
const applies = trimmed(flags.applies);
|
|
47
55
|
if (!rule)
|
|
48
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: '--rule is required for category convention.',
|
|
59
|
+
};
|
|
49
60
|
if (!applies)
|
|
50
|
-
return {
|
|
51
|
-
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: '--applies is required for category convention (where the rule applies).',
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
category: 'CONVENTION',
|
|
68
|
+
content: { rule, scope: applies },
|
|
69
|
+
};
|
|
52
70
|
}
|
|
53
71
|
case 'procedural': {
|
|
54
72
|
const workflow = trimmed(flags.workflow);
|
|
55
73
|
const trigger = trimmed(flags.trigger);
|
|
56
74
|
const steps = (flags.step ?? []).map(s => s.trim()).filter(Boolean);
|
|
57
75
|
if (!workflow)
|
|
58
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: '--workflow is required for category procedural.',
|
|
79
|
+
};
|
|
59
80
|
if (!trigger)
|
|
60
|
-
return {
|
|
61
|
-
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: '--trigger is required for category procedural.',
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
category: 'PROCEDURAL',
|
|
88
|
+
content: { workflow, trigger, ...(steps.length ? { steps } : {}) },
|
|
89
|
+
};
|
|
62
90
|
}
|
|
63
91
|
default:
|
|
64
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: `Unknown --category "${category}". Use trap | decision | convention | procedural.`,
|
|
95
|
+
};
|
|
65
96
|
}
|
|
66
97
|
}
|
|
67
98
|
function headline(category, content) {
|
|
68
99
|
const c = (content ?? {});
|
|
69
|
-
const key = category === 'TRAP'
|
|
70
|
-
|
|
71
|
-
|
|
100
|
+
const key = category === 'TRAP'
|
|
101
|
+
? 'trigger'
|
|
102
|
+
: category === 'DECISION'
|
|
103
|
+
? 'what'
|
|
104
|
+
: category === 'CONVENTION'
|
|
105
|
+
? 'rule'
|
|
72
106
|
: 'workflow';
|
|
73
107
|
const v = c[key];
|
|
74
|
-
return typeof v === 'string' && v.length > 0
|
|
108
|
+
return typeof v === 'string' && v.length > 0
|
|
109
|
+
? (0, sanitize_1.sanitizeField)(v)
|
|
110
|
+
: '(unparseable)';
|
|
75
111
|
}
|
|
76
112
|
/** Fixed-width rows: id SCOPE CATEGORY headline source(auto|manual). */
|
|
77
113
|
function formatMemoryList(rows) {
|
|
@@ -87,9 +123,3 @@ function formatMemoryList(rows) {
|
|
|
87
123
|
})
|
|
88
124
|
.join('\n');
|
|
89
125
|
}
|
|
90
|
-
/** Numbered review list: ` N. [SCOPE] CATEGORY headline`. 1-indexed. */
|
|
91
|
-
function formatMemoryReviewList(rows) {
|
|
92
|
-
return rows
|
|
93
|
-
.map((r, i) => ` ${i + 1}. [${r.scope}] ${r.category} ${headline(r.category, r.content)}`)
|
|
94
|
-
.join('\n');
|
|
95
|
-
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffSync = diffSync;
|
|
4
|
+
/**
|
|
5
|
+
* Pure reconcile: given the owned local files and the server bundle, classify
|
|
6
|
+
* each into add / update / remove / skipDrift. Idempotent — identical state
|
|
7
|
+
* yields empty add/update/remove. A locally-edited owned file (drift) is never
|
|
8
|
+
* an update or remove; it is reported as skipDrift so the dev's edit survives
|
|
9
|
+
* (and becomes a P3 upsync candidate).
|
|
10
|
+
*/
|
|
11
|
+
function diffSync(owned, bundle) {
|
|
12
|
+
const ownedById = new Map(owned.map(o => [o.id, o]));
|
|
13
|
+
const bundleById = new Map(bundle.map(b => [b.id, b]));
|
|
14
|
+
const add = bundle.filter(b => !ownedById.has(b.id));
|
|
15
|
+
const remove = [];
|
|
16
|
+
const update = [];
|
|
17
|
+
const skipDrift = [];
|
|
18
|
+
for (const o of owned) {
|
|
19
|
+
const b = bundleById.get(o.id);
|
|
20
|
+
if (o.diskHash !== o.contentHash) {
|
|
21
|
+
// locally edited → protect it regardless of whether the server changed
|
|
22
|
+
skipDrift.push(o.id);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!b) {
|
|
26
|
+
remove.push(o.id);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (b.contentHash !== o.contentHash)
|
|
30
|
+
update.push(b);
|
|
31
|
+
}
|
|
32
|
+
return { add, update, remove, skipDrift };
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.collectUpsyncCandidates = collectUpsyncCandidates;
|
|
4
|
+
exports.projectMemoriesEndpoint = projectMemoriesEndpoint;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const OUTBOX_DIR = 'outbox';
|
|
8
|
+
/**
|
|
9
|
+
* Collect upsync candidates from `<root>/outbox/*.json` — each a structured
|
|
10
|
+
* `{ category, content }` the dev authored locally and wants promoted to the
|
|
11
|
+
* team. JSON (not the rendered team/*.md files) keeps the content lossless and
|
|
12
|
+
* already category-shaped; a rendered team file cannot be reversed back to
|
|
13
|
+
* structured content, so edited team files (P1 drift candidates) are reported by
|
|
14
|
+
* `lumo memory sync`, not auto-converted here. Malformed/non-JSON files are
|
|
15
|
+
* skipped, not fatal.
|
|
16
|
+
*/
|
|
17
|
+
function collectUpsyncCandidates(root) {
|
|
18
|
+
const dir = (0, node_path_1.join)(root, OUTBOX_DIR);
|
|
19
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
20
|
+
return [];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const f of (0, node_fs_1.readdirSync)(dir).sort()) {
|
|
23
|
+
if (!f.endsWith('.json'))
|
|
24
|
+
continue;
|
|
25
|
+
const p = (0, node_path_1.join)(dir, f);
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
28
|
+
if (typeof parsed.category === 'string' && parsed.content !== undefined) {
|
|
29
|
+
out.push({
|
|
30
|
+
file: p,
|
|
31
|
+
category: parsed.category,
|
|
32
|
+
content: parsed.content,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// skip malformed JSON
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The endpoint upsync POSTs to. Deliberately the SAME create-project-memory
|
|
44
|
+
* route the web/CLI already use (`createForProject`: canonicalize → dedup →
|
|
45
|
+
* reconcile-on-write, LUM-538) — upsync reuses that pipeline rather than adding
|
|
46
|
+
* a parallel one.
|
|
47
|
+
*/
|
|
48
|
+
function projectMemoriesEndpoint(base, projectId) {
|
|
49
|
+
return `${base}/api/projects/${encodeURIComponent(projectId)}/memories`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LUM-547 (P4b) — code-anchor staleness detection.
|
|
4
|
+
*
|
|
5
|
+
* Pure logic: parse a memory's flattened text for high-confidence references to
|
|
6
|
+
* code "anchors" (file paths, symbols, flag/constant names), then — given an
|
|
7
|
+
* injected existence probe — decide whether the whole memory is stale (every
|
|
8
|
+
* anchor it names is gone from the repo).
|
|
9
|
+
*
|
|
10
|
+
* 高精度优先 / 宁缺毋滥: anchor extraction errs toward under-extraction
|
|
11
|
+
* (symbols & flags must be backtick-guarded; paths must be path-shaped with a
|
|
12
|
+
* known extension), and the staleness verdict errs toward keeping a memory (any
|
|
13
|
+
* single surviving anchor spares the whole memory). Detection only proposes a
|
|
14
|
+
* candidate; a human confirms before anything is archived (see retire.service).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.extractAnchors = extractAnchors;
|
|
18
|
+
exports.classifyMemoryStaleness = classifyMemoryStaleness;
|
|
19
|
+
const KNOWN_EXT = 'ts|tsx|js|jsx|mjs|cjs|json|prisma|md|mdx|css|scss|sass|sql|sh|yml|yaml|html|toml';
|
|
20
|
+
// A repo-relative path: one or more "dir/" segments then a filename.ext.
|
|
21
|
+
const PATH_RE = new RegExp(String.raw `(?<![\w@/.])((?:[\w.-]+/)+[\w.-]+\.(?:${KNOWN_EXT}))\b`, 'g');
|
|
22
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
23
|
+
const SCREAMING_SNAKE_RE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
|
|
24
|
+
// "Compound" identifier: camelCase, snake_case, or PascalCase-with-transition.
|
|
25
|
+
const COMPOUND_RE = /[a-z][A-Z]|_|^[A-Z][a-z0-9]+[A-Z]/;
|
|
26
|
+
function isExternalPath(p) {
|
|
27
|
+
return (p.startsWith('@') ||
|
|
28
|
+
p.startsWith('node_modules/') ||
|
|
29
|
+
p.includes('/node_modules/'));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract high-confidence code anchors from flattened memory text.
|
|
33
|
+
* - File paths: path-shaped tokens with a known extension (bare or backticked),
|
|
34
|
+
* excluding URLs and external packages.
|
|
35
|
+
* - Symbols: backtick-wrapped compound identifiers (camel/snake/Pascal), with
|
|
36
|
+
* single plain words and stop-words excluded.
|
|
37
|
+
* - Flags: backtick-wrapped SCREAMING_SNAKE constants.
|
|
38
|
+
* Result is de-duplicated, first-seen order.
|
|
39
|
+
*/
|
|
40
|
+
function extractAnchors(text) {
|
|
41
|
+
const out = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const push = (kind, value) => {
|
|
44
|
+
const key = `${kind}::${value}`;
|
|
45
|
+
if (seen.has(key))
|
|
46
|
+
return;
|
|
47
|
+
seen.add(key);
|
|
48
|
+
out.push({ kind, value });
|
|
49
|
+
};
|
|
50
|
+
// Strip URLs so their path-looking tails are never mistaken for repo paths.
|
|
51
|
+
const deUrled = text.replace(/https?:\/\/\S+/g, ' ');
|
|
52
|
+
// File paths (from anywhere in the text).
|
|
53
|
+
PATH_RE.lastIndex = 0;
|
|
54
|
+
for (let m = PATH_RE.exec(deUrled); m != null; m = PATH_RE.exec(deUrled)) {
|
|
55
|
+
const p = m[1];
|
|
56
|
+
if (p && !isExternalPath(p))
|
|
57
|
+
push('file', p);
|
|
58
|
+
}
|
|
59
|
+
// Symbols & flags (backtick-guarded only).
|
|
60
|
+
const backtickRe = /`([^`]+)`/g;
|
|
61
|
+
for (let m = backtickRe.exec(text); m != null; m = backtickRe.exec(text)) {
|
|
62
|
+
const inner = (m[1] ?? '').trim();
|
|
63
|
+
if (!IDENT_RE.test(inner))
|
|
64
|
+
continue; // has spaces / slashes / dots → not a bare identifier
|
|
65
|
+
if (SCREAMING_SNAKE_RE.test(inner)) {
|
|
66
|
+
push('flag', inner);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (inner.length >= 3 && COMPOUND_RE.test(inner)) {
|
|
70
|
+
push('symbol', inner);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function anchorExists(a, probe) {
|
|
76
|
+
return a.kind === 'file'
|
|
77
|
+
? probe.fileExists(a.value)
|
|
78
|
+
: probe.symbolExists(a.value);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether a memory's text is code-anchor stale. A memory is a stale
|
|
82
|
+
* candidate only when it names at least one anchor and EVERY anchor it names is
|
|
83
|
+
* absent from the repo (any single survivor spares it — the mis-kill guard). An
|
|
84
|
+
* anchor-free memory is never a candidate. `deadAnchors` always reports the
|
|
85
|
+
* absent anchors found (useful as evidence even when the memory is not stale).
|
|
86
|
+
*/
|
|
87
|
+
function classifyMemoryStaleness(text, probe) {
|
|
88
|
+
const anchors = extractAnchors(text);
|
|
89
|
+
const deadAnchors = anchors.filter(a => !anchorExists(a, probe));
|
|
90
|
+
const stale = anchors.length > 0 && deadAnchors.length === anchors.length;
|
|
91
|
+
return { stale, deadAnchors, anchorsFound: anchors.length };
|
|
92
|
+
}
|
package/dist/shared/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// ── Agent Error types ────────────────────────────────────────────────────────
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.capRenderedOutput = exports.truncateUnitsToBudget = exports.OUTPUT_TOKEN_BUDGET = exports.estimateTokens = exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
|
|
4
|
+
exports.classifyMemoryStaleness = exports.extractAnchors = exports.capRenderedOutput = exports.truncateUnitsToBudget = exports.OUTPUT_TOKEN_BUDGET = exports.estimateTokens = exports.buildCmdEvidencePointer = exports.isValidEvidencePointer = exports.EVIDENCE_POINTER_MAX = exports.EVIDENCE_POINTER_FORMAT_HINT = exports.EVIDENCE_POINTER_PATTERNS = exports.sanitizeField = exports.parseStreamJsonUsage = exports.tiptapToMarkdown = exports.markdownToTiptap = exports.AgentError = void 0;
|
|
5
5
|
exports.userFriendlyError = userFriendlyError;
|
|
6
6
|
class AgentError extends Error {
|
|
7
7
|
code;
|
|
@@ -52,3 +52,7 @@ Object.defineProperty(exports, "estimateTokens", { enumerable: true, get: functi
|
|
|
52
52
|
Object.defineProperty(exports, "OUTPUT_TOKEN_BUDGET", { enumerable: true, get: function () { return output_budget_1.OUTPUT_TOKEN_BUDGET; } });
|
|
53
53
|
Object.defineProperty(exports, "truncateUnitsToBudget", { enumerable: true, get: function () { return output_budget_1.truncateUnitsToBudget; } });
|
|
54
54
|
Object.defineProperty(exports, "capRenderedOutput", { enumerable: true, get: function () { return output_budget_1.capRenderedOutput; } });
|
|
55
|
+
// ── Memory code-anchor staleness detection (LUM-547) ─────────────────────────
|
|
56
|
+
var code_anchor_1 = require("./code-anchor");
|
|
57
|
+
Object.defineProperty(exports, "extractAnchors", { enumerable: true, get: function () { return code_anchor_1.extractAnchors; } });
|
|
58
|
+
Object.defineProperty(exports, "classifyMemoryStaleness", { enumerable: true, get: function () { return code_anchor_1.classifyMemoryStaleness; } });
|