@tricknowtech/context 0.1.0 → 0.2.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/README.md +27 -2
- package/dist/{chunk-DEH5MUFT.js → chunk-6GB2SN2L.js} +170 -4
- package/dist/cli.cjs +324 -16
- package/dist/cli.js +168 -14
- package/dist/index.cjs +175 -4
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +11 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -18,10 +18,17 @@ Open the same project on a second machine and the assistant knows nothing. Your
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
ctx push # collect context into the store
|
|
21
|
-
ctx pull # restore it
|
|
21
|
+
ctx pull # restore it, and print where you left off
|
|
22
22
|
ctx status # what changed since the last push
|
|
23
|
+
ctx handoff # show or write the session handoff
|
|
24
|
+
ctx doctor # check the setup is actually wired correctly
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
Start with `ctx doctor` if anything seems off — it verifies the pieces that
|
|
28
|
+
fail silently, including whether your store is accidentally gitignored (in
|
|
29
|
+
which case it never reaches the other machine) and whether restored memory
|
|
30
|
+
lands where Claude Code actually reads it.
|
|
31
|
+
|
|
25
32
|
Commit `.contextsync/` and your teammates — and your other laptop — get the same context on clone.
|
|
26
33
|
|
|
27
34
|
### It skips what git already carries
|
|
@@ -46,7 +53,25 @@ Claude Code names its per-project directories after the absolute project path
|
|
|
46
53
|
|
|
47
54
|
`/context push` asks the model to write `.contextsync/handoff.json` first — goal, decisions made, open threads, files touched, next step. Only the model has the conversation; only the CLI has the disk, so the slash command is the one place both are available.
|
|
48
55
|
|
|
49
|
-
On the other machine, `/context pull` restores everything and reads the handoff back, so the new session starts oriented instead of blank
|
|
56
|
+
On the other machine, `/context pull` restores everything and reads the handoff back, so the new session starts oriented instead of blank:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Where you left off (2h ago)
|
|
60
|
+
|
|
61
|
+
Goal Build the context-sync tool and publish it
|
|
62
|
+
Next step Start the cloud remote, leading with the token-scoping fix
|
|
63
|
+
|
|
64
|
+
Decided:
|
|
65
|
+
· Local-first: store is committed to the repo, cloud is an optional remote
|
|
66
|
+
Still open:
|
|
67
|
+
· mcp:* token abilities are granted but never checked
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The handoff is validated on write and on read — a model-authored file that's
|
|
71
|
+
malformed is reported rather than silently ignored, since a handoff that looks
|
|
72
|
+
present but says nothing is worse than an obviously absent one. `ctx push`
|
|
73
|
+
tells you when no handoff was written, so you never discover it only after
|
|
74
|
+
arriving on the other machine.
|
|
50
75
|
|
|
51
76
|
## Safety
|
|
52
77
|
|
|
@@ -228,12 +228,41 @@ var PROJECT_CONTEXT_GLOBS = [
|
|
|
228
228
|
".cursorrules",
|
|
229
229
|
".github/copilot-instructions.md",
|
|
230
230
|
".claude/settings.json",
|
|
231
|
+
// `.local.json` variants are gitignored by default, so nothing else carries
|
|
232
|
+
// them — which makes them exactly the kind of file this tool exists for.
|
|
233
|
+
".claude/settings.local.json",
|
|
231
234
|
".claude/memory/",
|
|
232
235
|
".claude/plans/",
|
|
233
236
|
".claude/commands/",
|
|
234
237
|
".claude/agents/",
|
|
235
238
|
".claude/skills/"
|
|
236
239
|
];
|
|
240
|
+
function planStem(fileName) {
|
|
241
|
+
return fileName.replace(/\.md$/, "").replace(/-agent-[0-9a-f]+$/i, "");
|
|
242
|
+
}
|
|
243
|
+
function collectPlans(plansDir, projectRoot, exclude) {
|
|
244
|
+
const all = walk(plansDir, { exclude });
|
|
245
|
+
if (all.length === 0) return [];
|
|
246
|
+
const projectName = path3.basename(projectRoot);
|
|
247
|
+
const related = /* @__PURE__ */ new Set();
|
|
248
|
+
const stems = /* @__PURE__ */ new Set();
|
|
249
|
+
for (const abs of all) {
|
|
250
|
+
let text = "";
|
|
251
|
+
try {
|
|
252
|
+
text = fs3.readFileSync(abs, "utf8");
|
|
253
|
+
} catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (text.includes(projectRoot) || text.includes(projectName)) {
|
|
257
|
+
related.add(abs);
|
|
258
|
+
stems.add(planStem(path3.basename(abs)));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const abs of all) {
|
|
262
|
+
if (stems.has(planStem(path3.basename(abs)))) related.add(abs);
|
|
263
|
+
}
|
|
264
|
+
return [...related];
|
|
265
|
+
}
|
|
237
266
|
function push(out, sourcePath, storePath, tier, sourceRoot, ctx) {
|
|
238
267
|
let size = 0;
|
|
239
268
|
try {
|
|
@@ -268,10 +297,24 @@ function collect(projectRoot, cfg, tiers) {
|
|
|
268
297
|
}
|
|
269
298
|
push(files, abs, `project/${rel}`, "core", "project", ctx);
|
|
270
299
|
}
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
const projectsDir = path3.join(userClaude, "projects");
|
|
301
|
+
let projectKeys = [];
|
|
302
|
+
try {
|
|
303
|
+
projectKeys = fs3.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === key || d.name.startsWith(key + "-"))).map((d) => d.name);
|
|
304
|
+
} catch {
|
|
305
|
+
projectKeys = [];
|
|
306
|
+
}
|
|
307
|
+
for (const pk of projectKeys) {
|
|
308
|
+
const memoryDir = path3.join(projectsDir, pk, "memory");
|
|
309
|
+
for (const abs of walk(memoryDir, { exclude })) {
|
|
310
|
+
const rel = toPosix(path3.relative(memoryDir, abs));
|
|
311
|
+
const storePath = pk === key ? `memory/${rel}` : `memory-sub/${pk.slice(key.length + 1)}/${rel}`;
|
|
312
|
+
push(files, abs, storePath, "core", "user", ctx);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const abs of collectPlans(path3.join(userClaude, "plans"), projectRoot, exclude)) {
|
|
316
|
+
const rel = toPosix(path3.relative(path3.join(userClaude, "plans"), abs));
|
|
317
|
+
push(files, abs, `plans/${rel}`, "core", "user", ctx);
|
|
275
318
|
}
|
|
276
319
|
const skillsDir = path3.join(userClaude, "skills");
|
|
277
320
|
for (const abs of walk(skillsDir, { exclude })) {
|
|
@@ -307,6 +350,44 @@ function collect(projectRoot, cfg, tiers) {
|
|
|
307
350
|
}
|
|
308
351
|
return { files, skippedTracked, ctx };
|
|
309
352
|
}
|
|
353
|
+
function describeExcluded(projectRoot, tiers) {
|
|
354
|
+
const userClaude = userClaudeDir();
|
|
355
|
+
const key = cwdKey(projectRoot);
|
|
356
|
+
const out = [];
|
|
357
|
+
const measure = (dir, filter) => {
|
|
358
|
+
let files = 0;
|
|
359
|
+
let bytes = 0;
|
|
360
|
+
for (const abs of walk(dir, { exclude: [] })) {
|
|
361
|
+
if (filter && !filter(abs)) continue;
|
|
362
|
+
files++;
|
|
363
|
+
try {
|
|
364
|
+
bytes += fs3.statSync(abs).size;
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return { files, bytes };
|
|
369
|
+
};
|
|
370
|
+
if (!tiers.includes("transcripts")) {
|
|
371
|
+
const projDir = path3.join(userClaude, "projects", key);
|
|
372
|
+
const m = measure(projDir, (p) => !p.includes(`${path3.sep}memory${path3.sep}`));
|
|
373
|
+
if (m.files > 0) {
|
|
374
|
+
out.push({
|
|
375
|
+
label: "session transcripts",
|
|
376
|
+
...m,
|
|
377
|
+
reason: "cloud-only \u2014 append-only logs this large would permanently bloat the repo"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
for (const [dir, label, reason] of [
|
|
382
|
+
["uploads", "pasted files/images", "session-scoped binaries; regenerate rather than sync"],
|
|
383
|
+
["file-history", "edit-undo snapshots", "transient local undo state, not portable context"],
|
|
384
|
+
["tasks", "task outputs", "session-scoped tool output"]
|
|
385
|
+
]) {
|
|
386
|
+
const m = measure(path3.join(userClaude, dir));
|
|
387
|
+
if (m.files > 0) out.push({ label, ...m, reason });
|
|
388
|
+
}
|
|
389
|
+
return out.filter((g) => g.bytes > 0);
|
|
390
|
+
}
|
|
310
391
|
function summarize(files) {
|
|
311
392
|
const empty = { count: 0, bytes: 0 };
|
|
312
393
|
const out = {
|
|
@@ -322,6 +403,86 @@ function summarize(files) {
|
|
|
322
403
|
return out;
|
|
323
404
|
}
|
|
324
405
|
|
|
406
|
+
// src/handoff.ts
|
|
407
|
+
function asStringArray(value) {
|
|
408
|
+
if (value === void 0 || value === null) return [];
|
|
409
|
+
if (!Array.isArray(value)) return null;
|
|
410
|
+
if (!value.every((v) => typeof v === "string")) return null;
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
function validateHandoff(raw) {
|
|
414
|
+
const errors = [];
|
|
415
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
416
|
+
return { ok: false, errors: ["handoff must be a JSON object"] };
|
|
417
|
+
}
|
|
418
|
+
const o = raw;
|
|
419
|
+
const goal = typeof o.goal === "string" ? o.goal.trim() : "";
|
|
420
|
+
const nextStep = typeof o.nextStep === "string" ? o.nextStep.trim() : "";
|
|
421
|
+
if (!goal) errors.push("`goal` is required (what the session set out to do)");
|
|
422
|
+
if (!nextStep) errors.push("`nextStep` is required (the single next action)");
|
|
423
|
+
const decisions = asStringArray(o.decisions);
|
|
424
|
+
const openThreads = asStringArray(o.openThreads);
|
|
425
|
+
const filesTouched = asStringArray(o.filesTouched);
|
|
426
|
+
if (decisions === null) errors.push("`decisions` must be an array of strings");
|
|
427
|
+
if (openThreads === null) errors.push("`openThreads` must be an array of strings");
|
|
428
|
+
if (filesTouched === null) errors.push("`filesTouched` must be an array of strings");
|
|
429
|
+
let updatedAt = typeof o.updatedAt === "string" ? o.updatedAt : "";
|
|
430
|
+
if (!updatedAt || Number.isNaN(Date.parse(updatedAt))) updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
431
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
errors: [],
|
|
435
|
+
handoff: {
|
|
436
|
+
updatedAt,
|
|
437
|
+
goal,
|
|
438
|
+
decisions: decisions ?? [],
|
|
439
|
+
openThreads: openThreads ?? [],
|
|
440
|
+
filesTouched: filesTouched ?? [],
|
|
441
|
+
nextStep,
|
|
442
|
+
...typeof o.notes === "string" && o.notes.trim() ? { notes: o.notes.trim() } : {}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function ago(iso) {
|
|
447
|
+
const ms = Date.now() - Date.parse(iso);
|
|
448
|
+
if (Number.isNaN(ms)) return "unknown";
|
|
449
|
+
const mins = Math.floor(ms / 6e4);
|
|
450
|
+
if (mins < 1) return "just now";
|
|
451
|
+
if (mins < 60) return `${mins}m ago`;
|
|
452
|
+
const hours = Math.floor(mins / 60);
|
|
453
|
+
if (hours < 24) return `${hours}h ago`;
|
|
454
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
455
|
+
}
|
|
456
|
+
function formatHandoff(h) {
|
|
457
|
+
const lines = [`Where you left off (${ago(h.updatedAt)})`, "", ` Goal ${h.goal}`, ` Next step ${h.nextStep}`];
|
|
458
|
+
if (h.decisions.length > 0) {
|
|
459
|
+
lines.push("", " Decided:");
|
|
460
|
+
for (const d of h.decisions) lines.push(` \xB7 ${d}`);
|
|
461
|
+
}
|
|
462
|
+
if (h.openThreads.length > 0) {
|
|
463
|
+
lines.push("", " Still open:");
|
|
464
|
+
for (const t of h.openThreads) lines.push(` \xB7 ${t}`);
|
|
465
|
+
}
|
|
466
|
+
if (h.filesTouched.length > 0) {
|
|
467
|
+
const shown = h.filesTouched.slice(0, 12);
|
|
468
|
+
lines.push("", " Files touched:");
|
|
469
|
+
for (const f of shown) lines.push(` ${f}`);
|
|
470
|
+
if (h.filesTouched.length > shown.length) {
|
|
471
|
+
lines.push(` \u2026 and ${h.filesTouched.length - shown.length} more`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (h.notes) lines.push("", ` Notes: ${h.notes}`);
|
|
475
|
+
return lines;
|
|
476
|
+
}
|
|
477
|
+
function handoffAge(h) {
|
|
478
|
+
return ago(h.updatedAt);
|
|
479
|
+
}
|
|
480
|
+
function isStale(h, newestContentMs) {
|
|
481
|
+
const t = Date.parse(h.updatedAt);
|
|
482
|
+
if (Number.isNaN(t)) return true;
|
|
483
|
+
return newestContentMs - t > 60 * 60 * 1e3;
|
|
484
|
+
}
|
|
485
|
+
|
|
325
486
|
// src/scaffold.ts
|
|
326
487
|
import fs4 from "fs";
|
|
327
488
|
import path4 from "path";
|
|
@@ -606,7 +767,12 @@ export {
|
|
|
606
767
|
loadConfig,
|
|
607
768
|
saveConfig,
|
|
608
769
|
collect,
|
|
770
|
+
describeExcluded,
|
|
609
771
|
summarize,
|
|
772
|
+
validateHandoff,
|
|
773
|
+
formatHandoff,
|
|
774
|
+
handoffAge,
|
|
775
|
+
isStale,
|
|
610
776
|
SLASH_COMMAND_PATH,
|
|
611
777
|
SLASH_COMMAND_BODY,
|
|
612
778
|
installSlashCommand,
|