@lumoai/cli 1.39.0 → 1.41.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 +99 -0
- package/assets/skill/references/sessions.md +49 -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/commands/session-attach.js +29 -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 +59 -0
- package/dist/cli/src/lib/claude-memory-dir.js +20 -0
- package/dist/cli/src/lib/hook-runner.js +28 -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-auto.js +114 -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/sync-throttle.js +71 -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
package/dist/cli/src/index.js
CHANGED
|
@@ -45,7 +45,6 @@ const whoami_1 = require("./commands/whoami");
|
|
|
45
45
|
const hook_1 = require("./commands/hook");
|
|
46
46
|
const session_attach_1 = require("./commands/session-attach");
|
|
47
47
|
const session_status_1 = require("./commands/session-status");
|
|
48
|
-
const session_wrap_1 = require("./commands/session-wrap");
|
|
49
48
|
const next_1 = require("./commands/next");
|
|
50
49
|
const cost_1 = require("./commands/cost");
|
|
51
50
|
const verify_1 = require("./commands/verify");
|
|
@@ -69,6 +68,8 @@ const memory_project_add_1 = require("./commands/memory-project-add");
|
|
|
69
68
|
const memory_promote_1 = require("./commands/memory-promote");
|
|
70
69
|
const memory_rm_1 = require("./commands/memory-rm");
|
|
71
70
|
const memory_show_1 = require("./commands/memory-show");
|
|
71
|
+
const memory_sync_1 = require("./commands/memory-sync");
|
|
72
|
+
const memory_push_1 = require("./commands/memory-push");
|
|
72
73
|
const task_artifact_add_1 = require("./commands/task-artifact-add");
|
|
73
74
|
const task_criteria_set_1 = require("./commands/task-criteria-set");
|
|
74
75
|
const task_criteria_list_1 = require("./commands/task-criteria-list");
|
|
@@ -264,13 +265,6 @@ session
|
|
|
264
265
|
.command('status')
|
|
265
266
|
.description('Show the task currently bound to this Claude Code session (or "no task" if none).')
|
|
266
267
|
.action(wrap(() => (0, session_status_1.sessionStatus)()));
|
|
267
|
-
session
|
|
268
|
-
.command('wrap')
|
|
269
|
-
.description('Session-end wrap-up: review the memories sedimented this session, vote which injected context fragments were actually used, and optionally flag the bound task blocked.')
|
|
270
|
-
.option('-y, --yes', 'Keep all memories without prompting (agent-friendly); does not auto-apply the blocked tag')
|
|
271
|
-
.option('--dry-run', 'Print the section drafts but do not mutate memories/tags or advance watermarks')
|
|
272
|
-
.option('--used <indices>', 'Mark which injected context fragments you actually used (1-based indices, comma/space separated; "none" for all-unused). Omit to skip recording.')
|
|
273
|
-
.action(wrap(options => (0, session_wrap_1.sessionWrap)(options)));
|
|
274
268
|
const task = program
|
|
275
269
|
.command('task')
|
|
276
270
|
.description('Inspect tasks from the terminal');
|
|
@@ -523,6 +517,22 @@ memoryCmd
|
|
|
523
517
|
.description('Delete a memory (hard delete). Requires --yes.')
|
|
524
518
|
.option('--yes', 'Confirm deletion')
|
|
525
519
|
.action(wrap((id, opts) => (0, memory_rm_1.memoryRm)(id, opts)));
|
|
520
|
+
memoryCmd
|
|
521
|
+
.command('sync')
|
|
522
|
+
.description('Downsync team memory into the local Claude Code memory store (team/*.md + a managed MEMORY.md block). Only touches files it owns; never clobbers your own memory. Resolves the project from the bound session unless --project is given.')
|
|
523
|
+
.option('--project <ref>', 'Project to sync (name/slug/id); default = bound session')
|
|
524
|
+
.option('--dir <path>', 'Target memory dir (default: ~/.claude/projects/<cwd>/memory)')
|
|
525
|
+
.option('--dry-run', 'Print the reconcile plan without writing files')
|
|
526
|
+
.option('--clean', 'Remove every file + index block sync owns (full rollback)')
|
|
527
|
+
.option('--no-anchor-check', 'Skip code-anchor staleness detection (the post-sync check that flags memories referencing deleted files/symbols)')
|
|
528
|
+
.action(wrap((opts) => (0, memory_sync_1.memorySync)(opts)));
|
|
529
|
+
memoryCmd
|
|
530
|
+
.command('push')
|
|
531
|
+
.description('Upsync locally-authored memories (<memory-dir>/outbox/*.json, each {category, content}) to the team via the existing create-project-memory pipeline. Pushed files are removed from the outbox on success.')
|
|
532
|
+
.option('--project <ref>', 'Project to push to (name/slug/id); default = bound session')
|
|
533
|
+
.option('--dir <path>', 'Memory dir (default: ~/.claude/projects/<cwd>/memory)')
|
|
534
|
+
.option('--dry-run', 'List what would be pushed without sending')
|
|
535
|
+
.action(wrap((opts) => (0, memory_push_1.memoryPush)(opts)));
|
|
526
536
|
const milestoneCmd = program
|
|
527
537
|
.command('milestone')
|
|
528
538
|
.description('Inspect milestones from the terminal');
|
|
@@ -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,59 @@
|
|
|
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
|
+
const sync_throttle_1 = require("./sync-throttle");
|
|
8
|
+
/**
|
|
9
|
+
* Apply a downsync bundle to the local memory dir. Owns only `root/team/*.md`
|
|
10
|
+
* and the MEMORY.md managed block; dev-authored files and out-of-block index
|
|
11
|
+
* content are never touched. `--clean` removes all owned files + the block;
|
|
12
|
+
* `--dry-run` computes the plan and writes nothing.
|
|
13
|
+
*/
|
|
14
|
+
function applySync(root, bundle, opts = {}) {
|
|
15
|
+
if (opts.clean) {
|
|
16
|
+
const owned = (0, local_memory_store_1.readOwned)(root);
|
|
17
|
+
if (!opts.dryRun) {
|
|
18
|
+
for (const o of owned)
|
|
19
|
+
(0, local_memory_store_1.removeEntry)(root, o.id);
|
|
20
|
+
(0, local_memory_store_1.writeIndex)(root, (0, managed_block_1.removeBlock)((0, local_memory_store_1.readIndex)(root)));
|
|
21
|
+
(0, sync_throttle_1.clearThrottleState)(root);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
added: [],
|
|
25
|
+
updated: [],
|
|
26
|
+
removed: owned.map(o => o.id),
|
|
27
|
+
skippedDrift: [],
|
|
28
|
+
noop: owned.length === 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const owned = (0, local_memory_store_1.readOwned)(root);
|
|
32
|
+
const plan = (0, memory_reconcile_1.diffSync)(owned, bundle.entries.map(e => ({ id: e.id, contentHash: e.contentHash })));
|
|
33
|
+
const byId = new Map(bundle.entries.map(e => [e.id, e]));
|
|
34
|
+
const summary = {
|
|
35
|
+
added: plan.add.map(a => a.id),
|
|
36
|
+
updated: plan.update.map(u => u.id),
|
|
37
|
+
removed: plan.remove,
|
|
38
|
+
skippedDrift: plan.skipDrift,
|
|
39
|
+
noop: plan.add.length === 0 &&
|
|
40
|
+
plan.update.length === 0 &&
|
|
41
|
+
plan.remove.length === 0,
|
|
42
|
+
};
|
|
43
|
+
if (opts.dryRun)
|
|
44
|
+
return summary;
|
|
45
|
+
for (const a of plan.add) {
|
|
46
|
+
const e = byId.get(a.id);
|
|
47
|
+
(0, local_memory_store_1.writeEntry)(root, e);
|
|
48
|
+
}
|
|
49
|
+
for (const u of plan.update) {
|
|
50
|
+
const e = byId.get(u.id);
|
|
51
|
+
(0, local_memory_store_1.writeEntry)(root, e);
|
|
52
|
+
}
|
|
53
|
+
for (const id of plan.remove)
|
|
54
|
+
(0, local_memory_store_1.removeEntry)(root, id);
|
|
55
|
+
// Index = every bundle entry's line (added/updated/unchanged/drift-skipped all
|
|
56
|
+
// still exist locally); removed ids are absent from the bundle so they drop out.
|
|
57
|
+
(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)));
|
|
58
|
+
return summary;
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -13,6 +13,9 @@ const sanitize_1 = require("./sanitize");
|
|
|
13
13
|
const agent_1 = require("./agent");
|
|
14
14
|
const git_task_1 = require("./git-task");
|
|
15
15
|
const transcript_usage_1 = require("./transcript-usage");
|
|
16
|
+
const resolve_project_1 = require("./resolve-project");
|
|
17
|
+
const resolve_bound_task_1 = require("./resolve-bound-task");
|
|
18
|
+
const memory_auto_1 = require("./memory-auto");
|
|
16
19
|
/**
|
|
17
20
|
* Hard timeout for the hook POST. On timeout the request is aborted,
|
|
18
21
|
* logged, and `runHook` exits 0 — Claude Code is never blocked beyond
|
|
@@ -329,6 +332,31 @@ async function runHookWithBody(path, body, agentToken) {
|
|
|
329
332
|
finally {
|
|
330
333
|
clearTimeout(timer);
|
|
331
334
|
}
|
|
335
|
+
// LUM-551: upsync drain on session-wrap-equivalent hooks. The outbox-empty
|
|
336
|
+
// fast path inside autoUpsyncOnDrain means most `stop` events do zero
|
|
337
|
+
// network (and never resolve the project). Best-effort: a failure is
|
|
338
|
+
// logged, never thrown — the hook still exits 0.
|
|
339
|
+
if (path === 'session-end' ||
|
|
340
|
+
path === 'stop' ||
|
|
341
|
+
path === 'task-completed') {
|
|
342
|
+
try {
|
|
343
|
+
await (0, memory_auto_1.autoUpsyncOnDrain)({
|
|
344
|
+
cwd: process.cwd(),
|
|
345
|
+
base: (0, api_1.trimTrailingSlash)(apiUrl),
|
|
346
|
+
token: creds.token,
|
|
347
|
+
resolveProjectId: async () => {
|
|
348
|
+
const bound = await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(apiUrl, creds.token);
|
|
349
|
+
if (!bound)
|
|
350
|
+
return null;
|
|
351
|
+
const r = await (0, resolve_project_1.resolveBoundProjectId)(apiUrl, creds.token, bound);
|
|
352
|
+
return r.ok ? r.id : null;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
(0, hook_log_1.logHookError)(`[${path}] upsync`, err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
332
360
|
}
|
|
333
361
|
catch (err) {
|
|
334
362
|
(0, hook_log_1.logHookError)(`[${path}] runner`, err);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.autoDownsyncOnAttach = autoDownsyncOnAttach;
|
|
4
|
+
exports.autoUpsyncOnDrain = autoUpsyncOnDrain;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const claude_memory_dir_1 = require("./claude-memory-dir");
|
|
7
|
+
const apply_sync_1 = require("./apply-sync");
|
|
8
|
+
const anchor_staleness_1 = require("./anchor-staleness");
|
|
9
|
+
const upsync_1 = require("./upsync");
|
|
10
|
+
const sync_throttle_1 = require("./sync-throttle");
|
|
11
|
+
/**
|
|
12
|
+
* LUM-551 — best-effort auto-triggers for team-memory sync/push.
|
|
13
|
+
*
|
|
14
|
+
* Both orchestrators are CLI-local, total `try/catch` (never throw), and quiet.
|
|
15
|
+
* They reuse the existing sync/push/anchor primitives rather than adding a
|
|
16
|
+
* parallel path. `LUMO_DISABLE_MEMORY_AUTO` turns both off wholesale.
|
|
17
|
+
*/
|
|
18
|
+
function autoDisabled() {
|
|
19
|
+
return !!process.env.LUMO_DISABLE_MEMORY_AUTO;
|
|
20
|
+
}
|
|
21
|
+
/** Fetch the team-memory sync bundle for a project. Throws on a non-2xx. */
|
|
22
|
+
async function fetchSyncBundle(base, token, projectId) {
|
|
23
|
+
const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/memories/sync-bundle`, { headers: { Authorization: `Bearer ${token}` } });
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
throw new Error(`sync-bundle HTTP ${res.status}`);
|
|
26
|
+
const { bundle } = (await res.json());
|
|
27
|
+
return bundle;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Attach-triggered best-effort downsync (LUM-551). Throttle-gated; swallows all
|
|
31
|
+
* errors into `{ error }`; NEVER throws. On a real sync it also runs the P4b
|
|
32
|
+
* anchor check (advisory) and records the throttle timestamp.
|
|
33
|
+
*/
|
|
34
|
+
async function autoDownsyncOnAttach(input) {
|
|
35
|
+
if (autoDisabled())
|
|
36
|
+
return { skipped: 'disabled' };
|
|
37
|
+
try {
|
|
38
|
+
const dir = (0, claude_memory_dir_1.resolveMemoryDir)(input.cwd, undefined, input.dir);
|
|
39
|
+
const state = (0, sync_throttle_1.readThrottleState)(dir);
|
|
40
|
+
if ((0, sync_throttle_1.isThrottled)(state, input.now, (0, sync_throttle_1.throttleHours)(), input.projectId)) {
|
|
41
|
+
return { skipped: 'throttled' };
|
|
42
|
+
}
|
|
43
|
+
const bundle = await fetchSyncBundle(input.base, input.token, input.projectId);
|
|
44
|
+
const summary = (0, apply_sync_1.applySync)(dir, bundle);
|
|
45
|
+
let anchorCandidates = 0;
|
|
46
|
+
try {
|
|
47
|
+
const { candidates, report } = await (0, anchor_staleness_1.runAnchorCheck)({
|
|
48
|
+
cwd: input.cwd,
|
|
49
|
+
base: input.base,
|
|
50
|
+
token: input.token,
|
|
51
|
+
projectId: input.projectId,
|
|
52
|
+
entries: bundle.entries,
|
|
53
|
+
});
|
|
54
|
+
if (report.ok)
|
|
55
|
+
anchorCandidates = candidates.length;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// anchor detection is advisory — never fail the sync over it
|
|
59
|
+
}
|
|
60
|
+
(0, sync_throttle_1.writeThrottleState)(dir, { lastSyncAt: input.now.toISOString(), projectId: input.projectId }, input.now);
|
|
61
|
+
return {
|
|
62
|
+
added: summary.added.length,
|
|
63
|
+
updated: summary.updated.length,
|
|
64
|
+
removed: summary.removed.length,
|
|
65
|
+
anchorCandidates,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Lifecycle-hook upsync drain (LUM-551). Outbox-empty fast path FIRST (no
|
|
74
|
+
* network); only a non-empty outbox resolves the project and pushes. Reuses the
|
|
75
|
+
* `memory-push` semantics (POST → unlink on 2xx). NEVER throws.
|
|
76
|
+
*/
|
|
77
|
+
async function autoUpsyncOnDrain(input) {
|
|
78
|
+
if (autoDisabled())
|
|
79
|
+
return { skipped: 'disabled' };
|
|
80
|
+
try {
|
|
81
|
+
const dir = (0, claude_memory_dir_1.resolveMemoryDir)(input.cwd, undefined, input.dir);
|
|
82
|
+
const candidates = (0, upsync_1.collectUpsyncCandidates)(dir);
|
|
83
|
+
if (candidates.length === 0)
|
|
84
|
+
return { skipped: 'empty' };
|
|
85
|
+
const projectId = await input.resolveProjectId();
|
|
86
|
+
if (!projectId)
|
|
87
|
+
return { error: 'no resolvable project for upsync' };
|
|
88
|
+
const url = (0, upsync_1.projectMemoriesEndpoint)(input.base, projectId);
|
|
89
|
+
let pushed = 0;
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${input.token}`,
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ category: c.category, content: c.content }),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
(0, node_fs_1.unlinkSync)(c.file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// best-effort cleanup; a re-push is idempotent via reconcile-on-write
|
|
106
|
+
}
|
|
107
|
+
pushed++;
|
|
108
|
+
}
|
|
109
|
+
return { pushed };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -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
|
-
}
|