@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/dist/cli.cjs CHANGED
@@ -36,6 +36,7 @@ __export(cli_exports, {
36
36
  module.exports = __toCommonJS(cli_exports);
37
37
 
38
38
  // src/commands.ts
39
+ var import_node_child_process2 = require("child_process");
39
40
  var import_node_fs7 = __toESM(require("fs"), 1);
40
41
  var import_node_path6 = __toESM(require("path"), 1);
41
42
 
@@ -273,12 +274,41 @@ var PROJECT_CONTEXT_GLOBS = [
273
274
  ".cursorrules",
274
275
  ".github/copilot-instructions.md",
275
276
  ".claude/settings.json",
277
+ // `.local.json` variants are gitignored by default, so nothing else carries
278
+ // them — which makes them exactly the kind of file this tool exists for.
279
+ ".claude/settings.local.json",
276
280
  ".claude/memory/",
277
281
  ".claude/plans/",
278
282
  ".claude/commands/",
279
283
  ".claude/agents/",
280
284
  ".claude/skills/"
281
285
  ];
286
+ function planStem(fileName) {
287
+ return fileName.replace(/\.md$/, "").replace(/-agent-[0-9a-f]+$/i, "");
288
+ }
289
+ function collectPlans(plansDir, projectRoot, exclude) {
290
+ const all = walk(plansDir, { exclude });
291
+ if (all.length === 0) return [];
292
+ const projectName = import_node_path3.default.basename(projectRoot);
293
+ const related = /* @__PURE__ */ new Set();
294
+ const stems = /* @__PURE__ */ new Set();
295
+ for (const abs of all) {
296
+ let text = "";
297
+ try {
298
+ text = import_node_fs3.default.readFileSync(abs, "utf8");
299
+ } catch {
300
+ continue;
301
+ }
302
+ if (text.includes(projectRoot) || text.includes(projectName)) {
303
+ related.add(abs);
304
+ stems.add(planStem(import_node_path3.default.basename(abs)));
305
+ }
306
+ }
307
+ for (const abs of all) {
308
+ if (stems.has(planStem(import_node_path3.default.basename(abs)))) related.add(abs);
309
+ }
310
+ return [...related];
311
+ }
282
312
  function push(out2, sourcePath, storePath, tier, sourceRoot, ctx) {
283
313
  let size = 0;
284
314
  try {
@@ -313,10 +343,24 @@ function collect(projectRoot, cfg, tiers) {
313
343
  }
314
344
  push(files, abs, `project/${rel}`, "core", "project", ctx);
315
345
  }
316
- const memoryDir = import_node_path3.default.join(userClaude, "projects", key, "memory");
317
- for (const abs of walk(memoryDir, { exclude })) {
318
- const rel = toPosix(import_node_path3.default.relative(memoryDir, abs));
319
- push(files, abs, `memory/${rel}`, "core", "user", ctx);
346
+ const projectsDir = import_node_path3.default.join(userClaude, "projects");
347
+ let projectKeys = [];
348
+ try {
349
+ projectKeys = import_node_fs3.default.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === key || d.name.startsWith(key + "-"))).map((d) => d.name);
350
+ } catch {
351
+ projectKeys = [];
352
+ }
353
+ for (const pk of projectKeys) {
354
+ const memoryDir = import_node_path3.default.join(projectsDir, pk, "memory");
355
+ for (const abs of walk(memoryDir, { exclude })) {
356
+ const rel = toPosix(import_node_path3.default.relative(memoryDir, abs));
357
+ const storePath = pk === key ? `memory/${rel}` : `memory-sub/${pk.slice(key.length + 1)}/${rel}`;
358
+ push(files, abs, storePath, "core", "user", ctx);
359
+ }
360
+ }
361
+ for (const abs of collectPlans(import_node_path3.default.join(userClaude, "plans"), projectRoot, exclude)) {
362
+ const rel = toPosix(import_node_path3.default.relative(import_node_path3.default.join(userClaude, "plans"), abs));
363
+ push(files, abs, `plans/${rel}`, "core", "user", ctx);
320
364
  }
321
365
  const skillsDir = import_node_path3.default.join(userClaude, "skills");
322
366
  for (const abs of walk(skillsDir, { exclude })) {
@@ -352,6 +396,44 @@ function collect(projectRoot, cfg, tiers) {
352
396
  }
353
397
  return { files, skippedTracked, ctx };
354
398
  }
399
+ function describeExcluded(projectRoot, tiers) {
400
+ const userClaude = userClaudeDir();
401
+ const key = cwdKey(projectRoot);
402
+ const out2 = [];
403
+ const measure = (dir, filter) => {
404
+ let files = 0;
405
+ let bytes = 0;
406
+ for (const abs of walk(dir, { exclude: [] })) {
407
+ if (filter && !filter(abs)) continue;
408
+ files++;
409
+ try {
410
+ bytes += import_node_fs3.default.statSync(abs).size;
411
+ } catch {
412
+ }
413
+ }
414
+ return { files, bytes };
415
+ };
416
+ if (!tiers.includes("transcripts")) {
417
+ const projDir = import_node_path3.default.join(userClaude, "projects", key);
418
+ const m = measure(projDir, (p) => !p.includes(`${import_node_path3.default.sep}memory${import_node_path3.default.sep}`));
419
+ if (m.files > 0) {
420
+ out2.push({
421
+ label: "session transcripts",
422
+ ...m,
423
+ reason: "cloud-only \u2014 append-only logs this large would permanently bloat the repo"
424
+ });
425
+ }
426
+ }
427
+ for (const [dir, label, reason] of [
428
+ ["uploads", "pasted files/images", "session-scoped binaries; regenerate rather than sync"],
429
+ ["file-history", "edit-undo snapshots", "transient local undo state, not portable context"],
430
+ ["tasks", "task outputs", "session-scoped tool output"]
431
+ ]) {
432
+ const m = measure(import_node_path3.default.join(userClaude, dir));
433
+ if (m.files > 0) out2.push({ label, ...m, reason });
434
+ }
435
+ return out2.filter((g) => g.bytes > 0);
436
+ }
355
437
  function summarize(files) {
356
438
  const empty = { count: 0, bytes: 0 };
357
439
  const out2 = {
@@ -367,6 +449,86 @@ function summarize(files) {
367
449
  return out2;
368
450
  }
369
451
 
452
+ // src/handoff.ts
453
+ function asStringArray(value) {
454
+ if (value === void 0 || value === null) return [];
455
+ if (!Array.isArray(value)) return null;
456
+ if (!value.every((v) => typeof v === "string")) return null;
457
+ return value;
458
+ }
459
+ function validateHandoff(raw) {
460
+ const errors = [];
461
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
462
+ return { ok: false, errors: ["handoff must be a JSON object"] };
463
+ }
464
+ const o = raw;
465
+ const goal = typeof o.goal === "string" ? o.goal.trim() : "";
466
+ const nextStep = typeof o.nextStep === "string" ? o.nextStep.trim() : "";
467
+ if (!goal) errors.push("`goal` is required (what the session set out to do)");
468
+ if (!nextStep) errors.push("`nextStep` is required (the single next action)");
469
+ const decisions = asStringArray(o.decisions);
470
+ const openThreads = asStringArray(o.openThreads);
471
+ const filesTouched = asStringArray(o.filesTouched);
472
+ if (decisions === null) errors.push("`decisions` must be an array of strings");
473
+ if (openThreads === null) errors.push("`openThreads` must be an array of strings");
474
+ if (filesTouched === null) errors.push("`filesTouched` must be an array of strings");
475
+ let updatedAt = typeof o.updatedAt === "string" ? o.updatedAt : "";
476
+ if (!updatedAt || Number.isNaN(Date.parse(updatedAt))) updatedAt = (/* @__PURE__ */ new Date()).toISOString();
477
+ if (errors.length > 0) return { ok: false, errors };
478
+ return {
479
+ ok: true,
480
+ errors: [],
481
+ handoff: {
482
+ updatedAt,
483
+ goal,
484
+ decisions: decisions ?? [],
485
+ openThreads: openThreads ?? [],
486
+ filesTouched: filesTouched ?? [],
487
+ nextStep,
488
+ ...typeof o.notes === "string" && o.notes.trim() ? { notes: o.notes.trim() } : {}
489
+ }
490
+ };
491
+ }
492
+ function ago(iso) {
493
+ const ms = Date.now() - Date.parse(iso);
494
+ if (Number.isNaN(ms)) return "unknown";
495
+ const mins = Math.floor(ms / 6e4);
496
+ if (mins < 1) return "just now";
497
+ if (mins < 60) return `${mins}m ago`;
498
+ const hours = Math.floor(mins / 60);
499
+ if (hours < 24) return `${hours}h ago`;
500
+ return `${Math.floor(hours / 24)}d ago`;
501
+ }
502
+ function formatHandoff(h) {
503
+ const lines = [`Where you left off (${ago(h.updatedAt)})`, "", ` Goal ${h.goal}`, ` Next step ${h.nextStep}`];
504
+ if (h.decisions.length > 0) {
505
+ lines.push("", " Decided:");
506
+ for (const d of h.decisions) lines.push(` \xB7 ${d}`);
507
+ }
508
+ if (h.openThreads.length > 0) {
509
+ lines.push("", " Still open:");
510
+ for (const t of h.openThreads) lines.push(` \xB7 ${t}`);
511
+ }
512
+ if (h.filesTouched.length > 0) {
513
+ const shown = h.filesTouched.slice(0, 12);
514
+ lines.push("", " Files touched:");
515
+ for (const f of shown) lines.push(` ${f}`);
516
+ if (h.filesTouched.length > shown.length) {
517
+ lines.push(` \u2026 and ${h.filesTouched.length - shown.length} more`);
518
+ }
519
+ }
520
+ if (h.notes) lines.push("", ` Notes: ${h.notes}`);
521
+ return lines;
522
+ }
523
+ function handoffAge(h) {
524
+ return ago(h.updatedAt);
525
+ }
526
+ function isStale(h, newestContentMs) {
527
+ const t = Date.parse(h.updatedAt);
528
+ if (Number.isNaN(t)) return true;
529
+ return newestContentMs - t > 60 * 60 * 1e3;
530
+ }
531
+
370
532
  // src/scaffold.ts
371
533
  var import_node_fs4 = __toESM(require("fs"), 1);
372
534
  var import_node_path4 = __toESM(require("path"), 1);
@@ -735,7 +897,28 @@ function cmdPush(opts = {}) {
735
897
  if (skippedTracked.length > 0) {
736
898
  lines.push("", `${skippedTracked.length} project files skipped \u2014 git already tracks them.`);
737
899
  }
900
+ const excluded = describeExcluded(root, tiers);
901
+ if (excluded.length > 0) {
902
+ lines.push("", "Not included (by design):");
903
+ for (const g of excluded) {
904
+ lines.push(` ${g.label.padEnd(22)} ${formatBytes(g.bytes).padStart(9)} ${g.reason}`);
905
+ }
906
+ }
738
907
  if (!opts.dryRun) {
908
+ const store = new LocalStore(root);
909
+ const raw = store.readHandoff();
910
+ const v = raw ? validateHandoff(raw) : null;
911
+ if (!raw) {
912
+ lines.push(
913
+ "",
914
+ "No handoff written \u2014 the other machine will get your project knowledge but",
915
+ "not where you left off. Use `/context push` in Claude Code to include one."
916
+ );
917
+ } else if (v && !v.ok) {
918
+ lines.push("", "Handoff present but malformed (it will be ignored):", ...v.errors.map((e) => ` \xB7 ${e}`));
919
+ } else if (v?.ok && isStale(v.handoff, Date.now())) {
920
+ lines.push("", `Handoff is ${handoffAge(v.handoff)} \u2014 re-run \`/context push\` to refresh it.`);
921
+ }
739
922
  lines.push("", "Commit .contextsync/ to carry this context with the repo.");
740
923
  }
741
924
  return ok(lines);
@@ -765,12 +948,108 @@ function cmdPull(opts = {}) {
765
948
  "Re-run with --force to overwrite them."
766
949
  );
767
950
  }
768
- const handoff = store.readHandoff();
769
- if (handoff) {
770
- lines.push("", `Handoff (${handoff.updatedAt}):`, ` goal: ${handoff.goal}`, ` next: ${handoff.nextStep}`);
951
+ const raw = store.readHandoff();
952
+ if (!raw) {
953
+ lines.push(
954
+ "",
955
+ "No handoff in this store \u2014 you have the project knowledge, but not where the",
956
+ "last session stopped. Run `/context push` (not bare `ctx push`) on the other",
957
+ "machine: only the model can write the handoff, since only it has the conversation."
958
+ );
959
+ return ok(lines);
960
+ }
961
+ const check = validateHandoff(raw);
962
+ if (!check.ok) {
963
+ lines.push("", "A handoff exists but is malformed and was ignored:", ...check.errors.map((e) => ` \xB7 ${e}`));
964
+ return ok(lines);
771
965
  }
966
+ lines.push("", ...formatHandoff(check.handoff));
772
967
  return ok(lines);
773
968
  }
969
+ function cmdHandoff(opts = {}) {
970
+ const found = requireProject();
971
+ if ("code" in found) return found;
972
+ const { root } = found;
973
+ const store = new LocalStore(root);
974
+ if (opts.set !== void 0) {
975
+ let parsed;
976
+ try {
977
+ parsed = JSON.parse(opts.set);
978
+ } catch (e) {
979
+ return fail([`Could not parse handoff JSON: ${e.message}`]);
980
+ }
981
+ const check2 = validateHandoff(parsed);
982
+ if (!check2.ok) return fail(["Handoff is not valid:", ...check2.errors.map((e) => ` \xB7 ${e}`)]);
983
+ store.writeHandoff(check2.handoff);
984
+ return ok([`Handoff saved (${import_node_path6.default.relative(root, storeDir(root))}/handoff.json).`, "", ...formatHandoff(check2.handoff)]);
985
+ }
986
+ const raw = store.readHandoff();
987
+ if (!raw) {
988
+ return ok([
989
+ "No handoff yet.",
990
+ "",
991
+ "Write one with `/context push` in Claude Code, or pipe JSON:",
992
+ ` ctx handoff --set '{"goal":"\u2026","nextStep":"\u2026"}'`
993
+ ]);
994
+ }
995
+ const check = validateHandoff(raw);
996
+ if (!check.ok) return fail(["Handoff is malformed:", ...check.errors.map((e) => ` \xB7 ${e}`)]);
997
+ return ok(formatHandoff(check.handoff));
998
+ }
999
+ function cmdDoctor() {
1000
+ const root = findProjectRoot();
1001
+ if (!root) return fail(["Not inside a project. Run `ctx init` first."]);
1002
+ const lines = [];
1003
+ let problems = 0;
1004
+ const check = (okFlag, label, detail) => {
1005
+ if (!okFlag) problems++;
1006
+ lines.push(` ${okFlag ? "\u2713" : "\u2717"} ${label.padEnd(28)} ${detail}`);
1007
+ };
1008
+ const cfg = loadConfig(root);
1009
+ check(Boolean(cfg), "store initialised", cfg ? `${import_node_path6.default.relative(root, storeDir(root))}/` : "missing \u2014 run `ctx init`");
1010
+ if (!cfg) return { code: 1, lines: ["Setup check", "", ...lines] };
1011
+ const store = new LocalStore(root);
1012
+ const manifest = store.readManifest();
1013
+ check(Boolean(manifest), "pushed at least once", manifest ? `${manifest.entries.length} files` : "never \u2014 run `ctx push`");
1014
+ check(
1015
+ import_node_fs7.default.existsSync(import_node_path6.default.join(root, SLASH_COMMAND_PATH)),
1016
+ "/context slash command",
1017
+ import_node_fs7.default.existsSync(import_node_path6.default.join(root, SLASH_COMMAND_PATH)) ? SLASH_COMMAND_PATH : `missing \u2014 run \`ctx init --force\``
1018
+ );
1019
+ const inGit = isGitRepo(root);
1020
+ check(inGit, "git repository", inGit ? "yes \u2014 store travels with the repo" : "no \u2014 the store will not sync anywhere");
1021
+ let storeIgnored = false;
1022
+ if (inGit) {
1023
+ try {
1024
+ (0, import_node_child_process2.execFileSync)("git", ["-C", root, "check-ignore", "-q", ".contextsync/config.json"], { stdio: "ignore" });
1025
+ storeIgnored = true;
1026
+ } catch {
1027
+ storeIgnored = false;
1028
+ }
1029
+ }
1030
+ check(!storeIgnored, "store is committable", storeIgnored ? "IGNORED by git \u2014 it will never reach another machine" : "not gitignored");
1031
+ const ctx = templateContext(root);
1032
+ const memEntry = manifest?.entries.find((e) => e.storePath.startsWith("memory/"));
1033
+ if (memEntry) {
1034
+ const dest = resolveTemplate(memEntry.restoreTemplate, ctx);
1035
+ const expectedDir = import_node_path6.default.join(userClaudeDir(), "projects", cwdKey(root), "memory");
1036
+ check(dest.startsWith(expectedDir), "memory restore path", dest.startsWith(expectedDir) ? expectedDir : `WRONG \u2192 ${dest}`);
1037
+ } else {
1038
+ check(false, "memory captured", "none found \u2014 is ~/.claude/projects/<key>/memory populated?");
1039
+ }
1040
+ const raw = store.readHandoff();
1041
+ if (!raw) {
1042
+ check(false, "handoff", "absent \u2014 `/context push` writes it; resume will have nothing to say");
1043
+ } else {
1044
+ const v = validateHandoff(raw);
1045
+ check(v.ok, "handoff", v.ok ? `valid, ${handoffAge(v.handoff)}` : v.errors[0]);
1046
+ if (v.ok && manifest && isStale(v.handoff, Date.parse(manifest.updatedAt))) {
1047
+ lines.push(" ! handoff is much older than the last push \u2014 re-run `/context push`");
1048
+ }
1049
+ }
1050
+ const header = problems === 0 ? "Setup check \u2014 all good" : `Setup check \u2014 ${problems} problem${problems === 1 ? "" : "s"}`;
1051
+ return { code: problems === 0 ? 0 : 1, lines: [header, "", ...lines] };
1052
+ }
774
1053
  function cmdStatus() {
775
1054
  const found = requireProject();
776
1055
  if ("code" in found) return found;
@@ -779,10 +1058,14 @@ function cmdStatus() {
779
1058
  const manifest = store.readManifest();
780
1059
  const { tiers } = effectiveTiers(cfg);
781
1060
  const { files, skippedTracked } = collect(root, cfg, tiers);
1061
+ const rawHandoff = store.readHandoff();
1062
+ const hv = rawHandoff ? validateHandoff(rawHandoff) : null;
1063
+ const handoffLabel = !rawHandoff ? "none \u2014 run `/context push` to record where you left off" : hv?.ok ? `${handoffAge(hv.handoff)}` : "malformed (will be ignored)";
782
1064
  const lines = [
783
1065
  `Project ${cfg.name}`,
784
1066
  `Store ${import_node_path6.default.relative(root, storeDir(root))}/`,
785
1067
  `Tiers ${cfg.tiers.join(", ")}`,
1068
+ `Handoff ${handoffLabel}`,
786
1069
  `Remotes ${Object.keys(cfg.remotes).length > 0 ? Object.keys(cfg.remotes).join(", ") : "none (local only)"}`,
787
1070
  ""
788
1071
  ];
@@ -824,13 +1107,16 @@ function cmdStatus() {
824
1107
  }
825
1108
 
826
1109
  // src/cli.ts
1110
+ var VERSION = "0.2.0";
827
1111
  var USAGE = `tricknowtech context-sync \u2014 carry a project's LLM context between machines
828
1112
 
829
1113
  Usage
830
1114
  ctx init [--artifacts] [--force] Create the store and install /context
831
1115
  ctx push [--dry-run] Collect context into the store
832
- ctx pull [--force] Restore context from the store
833
- ctx status Show what has changed since the last push
1116
+ ctx pull [--force] Restore context, and show where you left off
1117
+ ctx status What has changed since the last push
1118
+ ctx handoff [--set '<json>'] Show or write the session handoff
1119
+ ctx doctor Check the setup is actually wired correctly
834
1120
 
835
1121
  Options
836
1122
  --artifacts Include derived indexes (graphify-out/, etc.)
@@ -841,20 +1127,38 @@ Options
841
1127
  -v, --version Show version
842
1128
 
843
1129
  The store lives in .contextsync/ and is meant to be committed, so context
844
- travels with the code. Session transcripts are excluded from local mode.`;
1130
+ travels with the code. Session transcripts are excluded from local mode.
1131
+
1132
+ Prefer \`/context push\` inside Claude Code over bare \`ctx push\`: only the
1133
+ model can write the handoff that lets the next machine resume the work.`;
845
1134
  function parseArgs(argv) {
846
1135
  const flags = /* @__PURE__ */ new Set();
1136
+ const values = /* @__PURE__ */ new Map();
847
1137
  let command = "";
848
- for (const arg of argv) {
849
- if (arg.startsWith("-")) flags.add(arg.replace(/^-+/, ""));
850
- else if (!command) command = arg;
1138
+ for (let i = 0; i < argv.length; i++) {
1139
+ const arg = argv[i];
1140
+ if (arg.startsWith("-")) {
1141
+ const name = arg.replace(/^-+/, "");
1142
+ const eq = name.indexOf("=");
1143
+ if (eq !== -1) {
1144
+ values.set(name.slice(0, eq), name.slice(eq + 1));
1145
+ continue;
1146
+ }
1147
+ if (name === "set" && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
1148
+ values.set(name, argv[++i]);
1149
+ continue;
1150
+ }
1151
+ flags.add(name);
1152
+ } else if (!command) {
1153
+ command = arg;
1154
+ }
851
1155
  }
852
- return { command, flags };
1156
+ return { command, flags, values };
853
1157
  }
854
1158
  function run(argv) {
855
- const { command, flags } = parseArgs(argv);
1159
+ const { command, flags, values } = parseArgs(argv);
856
1160
  if (flags.has("h") || flags.has("help")) return { code: 0, lines: [USAGE] };
857
- if (flags.has("v") || flags.has("version")) return { code: 0, lines: ["0.1.0"] };
1161
+ if (flags.has("v") || flags.has("version")) return { code: 0, lines: [VERSION] };
858
1162
  switch (command) {
859
1163
  case "init":
860
1164
  return cmdInit({ artifacts: flags.has("artifacts"), force: flags.has("force") });
@@ -862,6 +1166,10 @@ function run(argv) {
862
1166
  return cmdPush({ allowSecrets: flags.has("allow-secrets"), dryRun: flags.has("dry-run") });
863
1167
  case "pull":
864
1168
  return cmdPull({ force: flags.has("force") });
1169
+ case "handoff":
1170
+ return cmdHandoff({ set: values.get("set") });
1171
+ case "doctor":
1172
+ return cmdDoctor();
865
1173
  case "status":
866
1174
  case "":
867
1175
  return cmdStatus();