@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 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 on another machine
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 memoryDir = path3.join(userClaude, "projects", key, "memory");
272
- for (const abs of walk(memoryDir, { exclude })) {
273
- const rel = toPosix(path3.relative(memoryDir, abs));
274
- push(files, abs, `memory/${rel}`, "core", "user", ctx);
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,