@tricknowtech/context 0.1.1 → 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
 
@@ -403,6 +403,86 @@ function summarize(files) {
403
403
  return out;
404
404
  }
405
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
+
406
486
  // src/scaffold.ts
407
487
  import fs4 from "fs";
408
488
  import path4 from "path";
@@ -689,6 +769,10 @@ export {
689
769
  collect,
690
770
  describeExcluded,
691
771
  summarize,
772
+ validateHandoff,
773
+ formatHandoff,
774
+ handoffAge,
775
+ isStale,
692
776
  SLASH_COMMAND_PATH,
693
777
  SLASH_COMMAND_BODY,
694
778
  installSlashCommand,
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
 
@@ -448,6 +449,86 @@ function summarize(files) {
448
449
  return out2;
449
450
  }
450
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
+
451
532
  // src/scaffold.ts
452
533
  var import_node_fs4 = __toESM(require("fs"), 1);
453
534
  var import_node_path4 = __toESM(require("path"), 1);
@@ -824,6 +905,20 @@ function cmdPush(opts = {}) {
824
905
  }
825
906
  }
826
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
+ }
827
922
  lines.push("", "Commit .contextsync/ to carry this context with the repo.");
828
923
  }
829
924
  return ok(lines);
@@ -853,12 +948,108 @@ function cmdPull(opts = {}) {
853
948
  "Re-run with --force to overwrite them."
854
949
  );
855
950
  }
856
- const handoff = store.readHandoff();
857
- if (handoff) {
858
- 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);
859
965
  }
966
+ lines.push("", ...formatHandoff(check.handoff));
860
967
  return ok(lines);
861
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
+ }
862
1053
  function cmdStatus() {
863
1054
  const found = requireProject();
864
1055
  if ("code" in found) return found;
@@ -867,10 +1058,14 @@ function cmdStatus() {
867
1058
  const manifest = store.readManifest();
868
1059
  const { tiers } = effectiveTiers(cfg);
869
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)";
870
1064
  const lines = [
871
1065
  `Project ${cfg.name}`,
872
1066
  `Store ${import_node_path6.default.relative(root, storeDir(root))}/`,
873
1067
  `Tiers ${cfg.tiers.join(", ")}`,
1068
+ `Handoff ${handoffLabel}`,
874
1069
  `Remotes ${Object.keys(cfg.remotes).length > 0 ? Object.keys(cfg.remotes).join(", ") : "none (local only)"}`,
875
1070
  ""
876
1071
  ];
@@ -912,13 +1107,16 @@ function cmdStatus() {
912
1107
  }
913
1108
 
914
1109
  // src/cli.ts
1110
+ var VERSION = "0.2.0";
915
1111
  var USAGE = `tricknowtech context-sync \u2014 carry a project's LLM context between machines
916
1112
 
917
1113
  Usage
918
1114
  ctx init [--artifacts] [--force] Create the store and install /context
919
1115
  ctx push [--dry-run] Collect context into the store
920
- ctx pull [--force] Restore context from the store
921
- 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
922
1120
 
923
1121
  Options
924
1122
  --artifacts Include derived indexes (graphify-out/, etc.)
@@ -929,20 +1127,38 @@ Options
929
1127
  -v, --version Show version
930
1128
 
931
1129
  The store lives in .contextsync/ and is meant to be committed, so context
932
- 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.`;
933
1134
  function parseArgs(argv) {
934
1135
  const flags = /* @__PURE__ */ new Set();
1136
+ const values = /* @__PURE__ */ new Map();
935
1137
  let command = "";
936
- for (const arg of argv) {
937
- if (arg.startsWith("-")) flags.add(arg.replace(/^-+/, ""));
938
- 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
+ }
939
1155
  }
940
- return { command, flags };
1156
+ return { command, flags, values };
941
1157
  }
942
1158
  function run(argv) {
943
- const { command, flags } = parseArgs(argv);
1159
+ const { command, flags, values } = parseArgs(argv);
944
1160
  if (flags.has("h") || flags.has("help")) return { code: 0, lines: [USAGE] };
945
- if (flags.has("v") || flags.has("version")) return { code: 0, lines: ["0.1.1"] };
1161
+ if (flags.has("v") || flags.has("version")) return { code: 0, lines: [VERSION] };
946
1162
  switch (command) {
947
1163
  case "init":
948
1164
  return cmdInit({ artifacts: flags.has("artifacts"), force: flags.has("force") });
@@ -950,6 +1166,10 @@ function run(argv) {
950
1166
  return cmdPush({ allowSecrets: flags.has("allow-secrets"), dryRun: flags.has("dry-run") });
951
1167
  case "pull":
952
1168
  return cmdPull({ force: flags.has("force") });
1169
+ case "handoff":
1170
+ return cmdHandoff({ set: values.get("set") });
1171
+ case "doctor":
1172
+ return cmdDoctor();
953
1173
  case "status":
954
1174
  case "":
955
1175
  return cmdStatus();
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  ALL_TIERS,
4
4
  LOCAL_TIERS,
5
5
  LocalStore,
6
+ SLASH_COMMAND_PATH,
6
7
  collect,
7
8
  configPath,
8
9
  cwdKey,
@@ -11,18 +12,24 @@ import {
11
12
  ensureGitignoreEntries,
12
13
  findProjectRoot,
13
14
  formatBytes,
15
+ formatHandoff,
14
16
  formatHits,
17
+ handoffAge,
15
18
  installSlashCommand,
16
19
  isGitRepo,
20
+ isStale,
17
21
  loadConfig,
22
+ resolveTemplate,
18
23
  saveConfig,
19
24
  scanFiles,
20
25
  storeDir,
21
26
  summarize,
22
- userClaudeDir
23
- } from "./chunk-BWATZKYM.js";
27
+ userClaudeDir,
28
+ validateHandoff
29
+ } from "./chunk-6GB2SN2L.js";
24
30
 
25
31
  // src/commands.ts
32
+ import { execFileSync } from "child_process";
26
33
  import fs from "fs";
27
34
  import path from "path";
28
35
  function ok(lines) {
@@ -138,6 +145,20 @@ function cmdPush(opts = {}) {
138
145
  }
139
146
  }
140
147
  if (!opts.dryRun) {
148
+ const store = new LocalStore(root);
149
+ const raw = store.readHandoff();
150
+ const v = raw ? validateHandoff(raw) : null;
151
+ if (!raw) {
152
+ lines.push(
153
+ "",
154
+ "No handoff written \u2014 the other machine will get your project knowledge but",
155
+ "not where you left off. Use `/context push` in Claude Code to include one."
156
+ );
157
+ } else if (v && !v.ok) {
158
+ lines.push("", "Handoff present but malformed (it will be ignored):", ...v.errors.map((e) => ` \xB7 ${e}`));
159
+ } else if (v?.ok && isStale(v.handoff, Date.now())) {
160
+ lines.push("", `Handoff is ${handoffAge(v.handoff)} \u2014 re-run \`/context push\` to refresh it.`);
161
+ }
141
162
  lines.push("", "Commit .contextsync/ to carry this context with the repo.");
142
163
  }
143
164
  return ok(lines);
@@ -167,12 +188,108 @@ function cmdPull(opts = {}) {
167
188
  "Re-run with --force to overwrite them."
168
189
  );
169
190
  }
170
- const handoff = store.readHandoff();
171
- if (handoff) {
172
- lines.push("", `Handoff (${handoff.updatedAt}):`, ` goal: ${handoff.goal}`, ` next: ${handoff.nextStep}`);
191
+ const raw = store.readHandoff();
192
+ if (!raw) {
193
+ lines.push(
194
+ "",
195
+ "No handoff in this store \u2014 you have the project knowledge, but not where the",
196
+ "last session stopped. Run `/context push` (not bare `ctx push`) on the other",
197
+ "machine: only the model can write the handoff, since only it has the conversation."
198
+ );
199
+ return ok(lines);
200
+ }
201
+ const check = validateHandoff(raw);
202
+ if (!check.ok) {
203
+ lines.push("", "A handoff exists but is malformed and was ignored:", ...check.errors.map((e) => ` \xB7 ${e}`));
204
+ return ok(lines);
173
205
  }
206
+ lines.push("", ...formatHandoff(check.handoff));
174
207
  return ok(lines);
175
208
  }
209
+ function cmdHandoff(opts = {}) {
210
+ const found = requireProject();
211
+ if ("code" in found) return found;
212
+ const { root } = found;
213
+ const store = new LocalStore(root);
214
+ if (opts.set !== void 0) {
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(opts.set);
218
+ } catch (e) {
219
+ return fail([`Could not parse handoff JSON: ${e.message}`]);
220
+ }
221
+ const check2 = validateHandoff(parsed);
222
+ if (!check2.ok) return fail(["Handoff is not valid:", ...check2.errors.map((e) => ` \xB7 ${e}`)]);
223
+ store.writeHandoff(check2.handoff);
224
+ return ok([`Handoff saved (${path.relative(root, storeDir(root))}/handoff.json).`, "", ...formatHandoff(check2.handoff)]);
225
+ }
226
+ const raw = store.readHandoff();
227
+ if (!raw) {
228
+ return ok([
229
+ "No handoff yet.",
230
+ "",
231
+ "Write one with `/context push` in Claude Code, or pipe JSON:",
232
+ ` ctx handoff --set '{"goal":"\u2026","nextStep":"\u2026"}'`
233
+ ]);
234
+ }
235
+ const check = validateHandoff(raw);
236
+ if (!check.ok) return fail(["Handoff is malformed:", ...check.errors.map((e) => ` \xB7 ${e}`)]);
237
+ return ok(formatHandoff(check.handoff));
238
+ }
239
+ function cmdDoctor() {
240
+ const root = findProjectRoot();
241
+ if (!root) return fail(["Not inside a project. Run `ctx init` first."]);
242
+ const lines = [];
243
+ let problems = 0;
244
+ const check = (okFlag, label, detail) => {
245
+ if (!okFlag) problems++;
246
+ lines.push(` ${okFlag ? "\u2713" : "\u2717"} ${label.padEnd(28)} ${detail}`);
247
+ };
248
+ const cfg = loadConfig(root);
249
+ check(Boolean(cfg), "store initialised", cfg ? `${path.relative(root, storeDir(root))}/` : "missing \u2014 run `ctx init`");
250
+ if (!cfg) return { code: 1, lines: ["Setup check", "", ...lines] };
251
+ const store = new LocalStore(root);
252
+ const manifest = store.readManifest();
253
+ check(Boolean(manifest), "pushed at least once", manifest ? `${manifest.entries.length} files` : "never \u2014 run `ctx push`");
254
+ check(
255
+ fs.existsSync(path.join(root, SLASH_COMMAND_PATH)),
256
+ "/context slash command",
257
+ fs.existsSync(path.join(root, SLASH_COMMAND_PATH)) ? SLASH_COMMAND_PATH : `missing \u2014 run \`ctx init --force\``
258
+ );
259
+ const inGit = isGitRepo(root);
260
+ check(inGit, "git repository", inGit ? "yes \u2014 store travels with the repo" : "no \u2014 the store will not sync anywhere");
261
+ let storeIgnored = false;
262
+ if (inGit) {
263
+ try {
264
+ execFileSync("git", ["-C", root, "check-ignore", "-q", ".contextsync/config.json"], { stdio: "ignore" });
265
+ storeIgnored = true;
266
+ } catch {
267
+ storeIgnored = false;
268
+ }
269
+ }
270
+ check(!storeIgnored, "store is committable", storeIgnored ? "IGNORED by git \u2014 it will never reach another machine" : "not gitignored");
271
+ const ctx = templateContext(root);
272
+ const memEntry = manifest?.entries.find((e) => e.storePath.startsWith("memory/"));
273
+ if (memEntry) {
274
+ const dest = resolveTemplate(memEntry.restoreTemplate, ctx);
275
+ const expectedDir = path.join(userClaudeDir(), "projects", cwdKey(root), "memory");
276
+ check(dest.startsWith(expectedDir), "memory restore path", dest.startsWith(expectedDir) ? expectedDir : `WRONG \u2192 ${dest}`);
277
+ } else {
278
+ check(false, "memory captured", "none found \u2014 is ~/.claude/projects/<key>/memory populated?");
279
+ }
280
+ const raw = store.readHandoff();
281
+ if (!raw) {
282
+ check(false, "handoff", "absent \u2014 `/context push` writes it; resume will have nothing to say");
283
+ } else {
284
+ const v = validateHandoff(raw);
285
+ check(v.ok, "handoff", v.ok ? `valid, ${handoffAge(v.handoff)}` : v.errors[0]);
286
+ if (v.ok && manifest && isStale(v.handoff, Date.parse(manifest.updatedAt))) {
287
+ lines.push(" ! handoff is much older than the last push \u2014 re-run `/context push`");
288
+ }
289
+ }
290
+ const header = problems === 0 ? "Setup check \u2014 all good" : `Setup check \u2014 ${problems} problem${problems === 1 ? "" : "s"}`;
291
+ return { code: problems === 0 ? 0 : 1, lines: [header, "", ...lines] };
292
+ }
176
293
  function cmdStatus() {
177
294
  const found = requireProject();
178
295
  if ("code" in found) return found;
@@ -181,10 +298,14 @@ function cmdStatus() {
181
298
  const manifest = store.readManifest();
182
299
  const { tiers } = effectiveTiers(cfg);
183
300
  const { files, skippedTracked } = collect(root, cfg, tiers);
301
+ const rawHandoff = store.readHandoff();
302
+ const hv = rawHandoff ? validateHandoff(rawHandoff) : null;
303
+ const handoffLabel = !rawHandoff ? "none \u2014 run `/context push` to record where you left off" : hv?.ok ? `${handoffAge(hv.handoff)}` : "malformed (will be ignored)";
184
304
  const lines = [
185
305
  `Project ${cfg.name}`,
186
306
  `Store ${path.relative(root, storeDir(root))}/`,
187
307
  `Tiers ${cfg.tiers.join(", ")}`,
308
+ `Handoff ${handoffLabel}`,
188
309
  `Remotes ${Object.keys(cfg.remotes).length > 0 ? Object.keys(cfg.remotes).join(", ") : "none (local only)"}`,
189
310
  ""
190
311
  ];
@@ -226,13 +347,16 @@ function cmdStatus() {
226
347
  }
227
348
 
228
349
  // src/cli.ts
350
+ var VERSION = "0.2.0";
229
351
  var USAGE = `tricknowtech context-sync \u2014 carry a project's LLM context between machines
230
352
 
231
353
  Usage
232
354
  ctx init [--artifacts] [--force] Create the store and install /context
233
355
  ctx push [--dry-run] Collect context into the store
234
- ctx pull [--force] Restore context from the store
235
- ctx status Show what has changed since the last push
356
+ ctx pull [--force] Restore context, and show where you left off
357
+ ctx status What has changed since the last push
358
+ ctx handoff [--set '<json>'] Show or write the session handoff
359
+ ctx doctor Check the setup is actually wired correctly
236
360
 
237
361
  Options
238
362
  --artifacts Include derived indexes (graphify-out/, etc.)
@@ -243,20 +367,38 @@ Options
243
367
  -v, --version Show version
244
368
 
245
369
  The store lives in .contextsync/ and is meant to be committed, so context
246
- travels with the code. Session transcripts are excluded from local mode.`;
370
+ travels with the code. Session transcripts are excluded from local mode.
371
+
372
+ Prefer \`/context push\` inside Claude Code over bare \`ctx push\`: only the
373
+ model can write the handoff that lets the next machine resume the work.`;
247
374
  function parseArgs(argv) {
248
375
  const flags = /* @__PURE__ */ new Set();
376
+ const values = /* @__PURE__ */ new Map();
249
377
  let command = "";
250
- for (const arg of argv) {
251
- if (arg.startsWith("-")) flags.add(arg.replace(/^-+/, ""));
252
- else if (!command) command = arg;
378
+ for (let i = 0; i < argv.length; i++) {
379
+ const arg = argv[i];
380
+ if (arg.startsWith("-")) {
381
+ const name = arg.replace(/^-+/, "");
382
+ const eq = name.indexOf("=");
383
+ if (eq !== -1) {
384
+ values.set(name.slice(0, eq), name.slice(eq + 1));
385
+ continue;
386
+ }
387
+ if (name === "set" && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
388
+ values.set(name, argv[++i]);
389
+ continue;
390
+ }
391
+ flags.add(name);
392
+ } else if (!command) {
393
+ command = arg;
394
+ }
253
395
  }
254
- return { command, flags };
396
+ return { command, flags, values };
255
397
  }
256
398
  function run(argv) {
257
- const { command, flags } = parseArgs(argv);
399
+ const { command, flags, values } = parseArgs(argv);
258
400
  if (flags.has("h") || flags.has("help")) return { code: 0, lines: [USAGE] };
259
- if (flags.has("v") || flags.has("version")) return { code: 0, lines: ["0.1.1"] };
401
+ if (flags.has("v") || flags.has("version")) return { code: 0, lines: [VERSION] };
260
402
  switch (command) {
261
403
  case "init":
262
404
  return cmdInit({ artifacts: flags.has("artifacts"), force: flags.has("force") });
@@ -264,6 +406,10 @@ function run(argv) {
264
406
  return cmdPush({ allowSecrets: flags.has("allow-secrets"), dryRun: flags.has("dry-run") });
265
407
  case "pull":
266
408
  return cmdPull({ force: flags.has("force") });
409
+ case "handoff":
410
+ return cmdHandoff({ set: values.get("set") });
411
+ case "doctor":
412
+ return cmdDoctor();
267
413
  case "status":
268
414
  case "":
269
415
  return cmdStatus();
package/dist/index.cjs CHANGED
@@ -45,13 +45,17 @@ __export(index_exports, {
45
45
  configPath: () => configPath,
46
46
  cwdKey: () => cwdKey,
47
47
  defaultConfig: () => defaultConfig,
48
+ describeExcluded: () => describeExcluded,
48
49
  ensureGitignoreEntries: () => ensureGitignoreEntries,
49
50
  findProjectRoot: () => findProjectRoot,
50
51
  formatBytes: () => formatBytes,
52
+ formatHandoff: () => formatHandoff,
51
53
  formatHits: () => formatHits,
52
54
  gitTrackedSet: () => gitTrackedSet,
55
+ handoffAge: () => handoffAge,
53
56
  installSlashCommand: () => installSlashCommand,
54
57
  isGitRepo: () => isGitRepo,
58
+ isStale: () => isStale,
55
59
  loadConfig: () => loadConfig,
56
60
  makeTemplate: () => makeTemplate,
57
61
  matchesAny: () => matchesAny,
@@ -61,6 +65,7 @@ __export(index_exports, {
61
65
  storeDir: () => storeDir,
62
66
  summarize: () => summarize,
63
67
  userClaudeDir: () => userClaudeDir,
68
+ validateHandoff: () => validateHandoff,
64
69
  walk: () => walk
65
70
  });
66
71
  module.exports = __toCommonJS(index_exports);
@@ -421,6 +426,44 @@ function collect(projectRoot, cfg, tiers) {
421
426
  }
422
427
  return { files, skippedTracked, ctx };
423
428
  }
429
+ function describeExcluded(projectRoot, tiers) {
430
+ const userClaude = userClaudeDir();
431
+ const key = cwdKey(projectRoot);
432
+ const out = [];
433
+ const measure = (dir, filter) => {
434
+ let files = 0;
435
+ let bytes = 0;
436
+ for (const abs of walk(dir, { exclude: [] })) {
437
+ if (filter && !filter(abs)) continue;
438
+ files++;
439
+ try {
440
+ bytes += import_node_fs3.default.statSync(abs).size;
441
+ } catch {
442
+ }
443
+ }
444
+ return { files, bytes };
445
+ };
446
+ if (!tiers.includes("transcripts")) {
447
+ const projDir = import_node_path3.default.join(userClaude, "projects", key);
448
+ const m = measure(projDir, (p) => !p.includes(`${import_node_path3.default.sep}memory${import_node_path3.default.sep}`));
449
+ if (m.files > 0) {
450
+ out.push({
451
+ label: "session transcripts",
452
+ ...m,
453
+ reason: "cloud-only \u2014 append-only logs this large would permanently bloat the repo"
454
+ });
455
+ }
456
+ }
457
+ for (const [dir, label, reason] of [
458
+ ["uploads", "pasted files/images", "session-scoped binaries; regenerate rather than sync"],
459
+ ["file-history", "edit-undo snapshots", "transient local undo state, not portable context"],
460
+ ["tasks", "task outputs", "session-scoped tool output"]
461
+ ]) {
462
+ const m = measure(import_node_path3.default.join(userClaude, dir));
463
+ if (m.files > 0) out.push({ label, ...m, reason });
464
+ }
465
+ return out.filter((g) => g.bytes > 0);
466
+ }
424
467
  function summarize(files) {
425
468
  const empty = { count: 0, bytes: 0 };
426
469
  const out = {
@@ -436,6 +479,86 @@ function summarize(files) {
436
479
  return out;
437
480
  }
438
481
 
482
+ // src/handoff.ts
483
+ function asStringArray(value) {
484
+ if (value === void 0 || value === null) return [];
485
+ if (!Array.isArray(value)) return null;
486
+ if (!value.every((v) => typeof v === "string")) return null;
487
+ return value;
488
+ }
489
+ function validateHandoff(raw) {
490
+ const errors = [];
491
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
492
+ return { ok: false, errors: ["handoff must be a JSON object"] };
493
+ }
494
+ const o = raw;
495
+ const goal = typeof o.goal === "string" ? o.goal.trim() : "";
496
+ const nextStep = typeof o.nextStep === "string" ? o.nextStep.trim() : "";
497
+ if (!goal) errors.push("`goal` is required (what the session set out to do)");
498
+ if (!nextStep) errors.push("`nextStep` is required (the single next action)");
499
+ const decisions = asStringArray(o.decisions);
500
+ const openThreads = asStringArray(o.openThreads);
501
+ const filesTouched = asStringArray(o.filesTouched);
502
+ if (decisions === null) errors.push("`decisions` must be an array of strings");
503
+ if (openThreads === null) errors.push("`openThreads` must be an array of strings");
504
+ if (filesTouched === null) errors.push("`filesTouched` must be an array of strings");
505
+ let updatedAt = typeof o.updatedAt === "string" ? o.updatedAt : "";
506
+ if (!updatedAt || Number.isNaN(Date.parse(updatedAt))) updatedAt = (/* @__PURE__ */ new Date()).toISOString();
507
+ if (errors.length > 0) return { ok: false, errors };
508
+ return {
509
+ ok: true,
510
+ errors: [],
511
+ handoff: {
512
+ updatedAt,
513
+ goal,
514
+ decisions: decisions ?? [],
515
+ openThreads: openThreads ?? [],
516
+ filesTouched: filesTouched ?? [],
517
+ nextStep,
518
+ ...typeof o.notes === "string" && o.notes.trim() ? { notes: o.notes.trim() } : {}
519
+ }
520
+ };
521
+ }
522
+ function ago(iso) {
523
+ const ms = Date.now() - Date.parse(iso);
524
+ if (Number.isNaN(ms)) return "unknown";
525
+ const mins = Math.floor(ms / 6e4);
526
+ if (mins < 1) return "just now";
527
+ if (mins < 60) return `${mins}m ago`;
528
+ const hours = Math.floor(mins / 60);
529
+ if (hours < 24) return `${hours}h ago`;
530
+ return `${Math.floor(hours / 24)}d ago`;
531
+ }
532
+ function formatHandoff(h) {
533
+ const lines = [`Where you left off (${ago(h.updatedAt)})`, "", ` Goal ${h.goal}`, ` Next step ${h.nextStep}`];
534
+ if (h.decisions.length > 0) {
535
+ lines.push("", " Decided:");
536
+ for (const d of h.decisions) lines.push(` \xB7 ${d}`);
537
+ }
538
+ if (h.openThreads.length > 0) {
539
+ lines.push("", " Still open:");
540
+ for (const t of h.openThreads) lines.push(` \xB7 ${t}`);
541
+ }
542
+ if (h.filesTouched.length > 0) {
543
+ const shown = h.filesTouched.slice(0, 12);
544
+ lines.push("", " Files touched:");
545
+ for (const f of shown) lines.push(` ${f}`);
546
+ if (h.filesTouched.length > shown.length) {
547
+ lines.push(` \u2026 and ${h.filesTouched.length - shown.length} more`);
548
+ }
549
+ }
550
+ if (h.notes) lines.push("", ` Notes: ${h.notes}`);
551
+ return lines;
552
+ }
553
+ function handoffAge(h) {
554
+ return ago(h.updatedAt);
555
+ }
556
+ function isStale(h, newestContentMs) {
557
+ const t = Date.parse(h.updatedAt);
558
+ if (Number.isNaN(t)) return true;
559
+ return newestContentMs - t > 60 * 60 * 1e3;
560
+ }
561
+
439
562
  // src/scaffold.ts
440
563
  var import_node_fs4 = __toESM(require("fs"), 1);
441
564
  var import_node_path4 = __toESM(require("path"), 1);
@@ -714,13 +837,17 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
714
837
  configPath,
715
838
  cwdKey,
716
839
  defaultConfig,
840
+ describeExcluded,
717
841
  ensureGitignoreEntries,
718
842
  findProjectRoot,
719
843
  formatBytes,
844
+ formatHandoff,
720
845
  formatHits,
721
846
  gitTrackedSet,
847
+ handoffAge,
722
848
  installSlashCommand,
723
849
  isGitRepo,
850
+ isStale,
724
851
  loadConfig,
725
852
  makeTemplate,
726
853
  matchesAny,
@@ -730,5 +857,6 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
730
857
  storeDir,
731
858
  summarize,
732
859
  userClaudeDir,
860
+ validateHandoff,
733
861
  walk
734
862
  });
package/dist/index.d.cts CHANGED
@@ -155,11 +155,47 @@ interface CollectResult {
155
155
  * repo and are exactly what never makes it to a second machine today.
156
156
  */
157
157
  declare function collect(projectRoot: string, cfg: ProjectConfig, tiers: Tier[]): CollectResult;
158
+ interface ExcludedGroup {
159
+ label: string;
160
+ files: number;
161
+ bytes: number;
162
+ reason: string;
163
+ }
164
+ /**
165
+ * What was deliberately left behind, and why.
166
+ *
167
+ * Without this a user sees "13 files synced" against a ~400 MB context
168
+ * directory and reasonably concludes the tool is broken. Naming the omissions
169
+ * — with sizes — is the difference between a considered exclusion and a
170
+ * silent one.
171
+ */
172
+ declare function describeExcluded(projectRoot: string, tiers: Tier[]): ExcludedGroup[];
158
173
  declare function summarize(files: CollectedFile[]): Record<Tier, {
159
174
  count: number;
160
175
  bytes: number;
161
176
  }>;
162
177
 
178
+ /**
179
+ * The handoff is the whole point of the tool — files alone tell you what a
180
+ * project knows, but not where you stopped. It is written by the model (only
181
+ * it has the conversation) and read back on the other machine.
182
+ *
183
+ * Because a model writes it, it needs real validation: a silently malformed
184
+ * handoff is worse than none, since `pull` would look like it worked while
185
+ * handing back nothing usable.
186
+ */
187
+ interface ValidationResult {
188
+ ok: boolean;
189
+ errors: string[];
190
+ handoff?: Handoff;
191
+ }
192
+ declare function validateHandoff(raw: unknown): ValidationResult;
193
+ /** Render a handoff for a human picking the work back up. */
194
+ declare function formatHandoff(h: Handoff): string[];
195
+ declare function handoffAge(h: Handoff): string;
196
+ /** True when the handoff predates the newest synced content by a wide margin. */
197
+ declare function isStale(h: Handoff, newestContentMs: number): boolean;
198
+
163
199
  declare const STORE_DIR = ".contextsync";
164
200
  declare const CONFIG_FILE = "config.json";
165
201
  declare const HANDOFF_FILE = "handoff.json";
@@ -266,4 +302,4 @@ declare class LocalStore implements Store {
266
302
  writeHandoff(handoff: Handoff): void;
267
303
  }
268
304
 
269
- export { ALL_TIERS, CONFIG_FILE, type CollectResult, type CollectedFile, DEFAULT_EXCLUDE, HANDOFF_FILE, HARD_DENY, type Handoff, LOCAL_TIERS, LocalStore, MANIFEST_FILE, type Manifest, type ManifestEntry, type ProjectConfig, type RemoteConfig, type RestoreResult, SLASH_COMMAND_BODY, SLASH_COMMAND_PATH, STORE_DIR, type SecretHit, type SourceRoot, type Store, type TemplateContext, type Tier, collect, configPath, cwdKey, defaultConfig, ensureGitignoreEntries, findProjectRoot, formatBytes, formatHits, gitTrackedSet, installSlashCommand, isGitRepo, loadConfig, makeTemplate, matchesAny, resolveTemplate, saveConfig, scanFiles, storeDir, summarize, userClaudeDir, walk };
305
+ export { ALL_TIERS, CONFIG_FILE, type CollectResult, type CollectedFile, DEFAULT_EXCLUDE, type ExcludedGroup, HANDOFF_FILE, HARD_DENY, type Handoff, LOCAL_TIERS, LocalStore, MANIFEST_FILE, type Manifest, type ManifestEntry, type ProjectConfig, type RemoteConfig, type RestoreResult, SLASH_COMMAND_BODY, SLASH_COMMAND_PATH, STORE_DIR, type SecretHit, type SourceRoot, type Store, type TemplateContext, type Tier, type ValidationResult, collect, configPath, cwdKey, defaultConfig, describeExcluded, ensureGitignoreEntries, findProjectRoot, formatBytes, formatHandoff, formatHits, gitTrackedSet, handoffAge, installSlashCommand, isGitRepo, isStale, loadConfig, makeTemplate, matchesAny, resolveTemplate, saveConfig, scanFiles, storeDir, summarize, userClaudeDir, validateHandoff, walk };
package/dist/index.d.ts CHANGED
@@ -155,11 +155,47 @@ interface CollectResult {
155
155
  * repo and are exactly what never makes it to a second machine today.
156
156
  */
157
157
  declare function collect(projectRoot: string, cfg: ProjectConfig, tiers: Tier[]): CollectResult;
158
+ interface ExcludedGroup {
159
+ label: string;
160
+ files: number;
161
+ bytes: number;
162
+ reason: string;
163
+ }
164
+ /**
165
+ * What was deliberately left behind, and why.
166
+ *
167
+ * Without this a user sees "13 files synced" against a ~400 MB context
168
+ * directory and reasonably concludes the tool is broken. Naming the omissions
169
+ * — with sizes — is the difference between a considered exclusion and a
170
+ * silent one.
171
+ */
172
+ declare function describeExcluded(projectRoot: string, tiers: Tier[]): ExcludedGroup[];
158
173
  declare function summarize(files: CollectedFile[]): Record<Tier, {
159
174
  count: number;
160
175
  bytes: number;
161
176
  }>;
162
177
 
178
+ /**
179
+ * The handoff is the whole point of the tool — files alone tell you what a
180
+ * project knows, but not where you stopped. It is written by the model (only
181
+ * it has the conversation) and read back on the other machine.
182
+ *
183
+ * Because a model writes it, it needs real validation: a silently malformed
184
+ * handoff is worse than none, since `pull` would look like it worked while
185
+ * handing back nothing usable.
186
+ */
187
+ interface ValidationResult {
188
+ ok: boolean;
189
+ errors: string[];
190
+ handoff?: Handoff;
191
+ }
192
+ declare function validateHandoff(raw: unknown): ValidationResult;
193
+ /** Render a handoff for a human picking the work back up. */
194
+ declare function formatHandoff(h: Handoff): string[];
195
+ declare function handoffAge(h: Handoff): string;
196
+ /** True when the handoff predates the newest synced content by a wide margin. */
197
+ declare function isStale(h: Handoff, newestContentMs: number): boolean;
198
+
163
199
  declare const STORE_DIR = ".contextsync";
164
200
  declare const CONFIG_FILE = "config.json";
165
201
  declare const HANDOFF_FILE = "handoff.json";
@@ -266,4 +302,4 @@ declare class LocalStore implements Store {
266
302
  writeHandoff(handoff: Handoff): void;
267
303
  }
268
304
 
269
- export { ALL_TIERS, CONFIG_FILE, type CollectResult, type CollectedFile, DEFAULT_EXCLUDE, HANDOFF_FILE, HARD_DENY, type Handoff, LOCAL_TIERS, LocalStore, MANIFEST_FILE, type Manifest, type ManifestEntry, type ProjectConfig, type RemoteConfig, type RestoreResult, SLASH_COMMAND_BODY, SLASH_COMMAND_PATH, STORE_DIR, type SecretHit, type SourceRoot, type Store, type TemplateContext, type Tier, collect, configPath, cwdKey, defaultConfig, ensureGitignoreEntries, findProjectRoot, formatBytes, formatHits, gitTrackedSet, installSlashCommand, isGitRepo, loadConfig, makeTemplate, matchesAny, resolveTemplate, saveConfig, scanFiles, storeDir, summarize, userClaudeDir, walk };
305
+ export { ALL_TIERS, CONFIG_FILE, type CollectResult, type CollectedFile, DEFAULT_EXCLUDE, type ExcludedGroup, HANDOFF_FILE, HARD_DENY, type Handoff, LOCAL_TIERS, LocalStore, MANIFEST_FILE, type Manifest, type ManifestEntry, type ProjectConfig, type RemoteConfig, type RestoreResult, SLASH_COMMAND_BODY, SLASH_COMMAND_PATH, STORE_DIR, type SecretHit, type SourceRoot, type Store, type TemplateContext, type Tier, type ValidationResult, collect, configPath, cwdKey, defaultConfig, describeExcluded, ensureGitignoreEntries, findProjectRoot, formatBytes, formatHandoff, formatHits, gitTrackedSet, handoffAge, installSlashCommand, isGitRepo, isStale, loadConfig, makeTemplate, matchesAny, resolveTemplate, saveConfig, scanFiles, storeDir, summarize, userClaudeDir, validateHandoff, walk };
package/dist/index.js CHANGED
@@ -14,13 +14,17 @@ import {
14
14
  configPath,
15
15
  cwdKey,
16
16
  defaultConfig,
17
+ describeExcluded,
17
18
  ensureGitignoreEntries,
18
19
  findProjectRoot,
19
20
  formatBytes,
21
+ formatHandoff,
20
22
  formatHits,
21
23
  gitTrackedSet,
24
+ handoffAge,
22
25
  installSlashCommand,
23
26
  isGitRepo,
27
+ isStale,
24
28
  loadConfig,
25
29
  makeTemplate,
26
30
  matchesAny,
@@ -30,8 +34,9 @@ import {
30
34
  storeDir,
31
35
  summarize,
32
36
  userClaudeDir,
37
+ validateHandoff,
33
38
  walk
34
- } from "./chunk-BWATZKYM.js";
39
+ } from "./chunk-6GB2SN2L.js";
35
40
  export {
36
41
  ALL_TIERS,
37
42
  CONFIG_FILE,
@@ -48,13 +53,17 @@ export {
48
53
  configPath,
49
54
  cwdKey,
50
55
  defaultConfig,
56
+ describeExcluded,
51
57
  ensureGitignoreEntries,
52
58
  findProjectRoot,
53
59
  formatBytes,
60
+ formatHandoff,
54
61
  formatHits,
55
62
  gitTrackedSet,
63
+ handoffAge,
56
64
  installSlashCommand,
57
65
  isGitRepo,
66
+ isStale,
58
67
  loadConfig,
59
68
  makeTemplate,
60
69
  matchesAny,
@@ -64,5 +73,6 @@ export {
64
73
  storeDir,
65
74
  summarize,
66
75
  userClaudeDir,
76
+ validateHandoff,
67
77
  walk
68
78
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tricknowtech/context",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Carry a project's LLM context — memory, skills, instructions and session handoff — between machines. Local-first, commit it to your repo.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",