@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.js CHANGED
@@ -3,25 +3,33 @@ 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,
9
10
  defaultConfig,
11
+ describeExcluded,
10
12
  ensureGitignoreEntries,
11
13
  findProjectRoot,
12
14
  formatBytes,
15
+ formatHandoff,
13
16
  formatHits,
17
+ handoffAge,
14
18
  installSlashCommand,
15
19
  isGitRepo,
20
+ isStale,
16
21
  loadConfig,
22
+ resolveTemplate,
17
23
  saveConfig,
18
24
  scanFiles,
19
25
  storeDir,
20
26
  summarize,
21
- userClaudeDir
22
- } from "./chunk-DEH5MUFT.js";
27
+ userClaudeDir,
28
+ validateHandoff
29
+ } from "./chunk-6GB2SN2L.js";
23
30
 
24
31
  // src/commands.ts
32
+ import { execFileSync } from "child_process";
25
33
  import fs from "fs";
26
34
  import path from "path";
27
35
  function ok(lines) {
@@ -129,7 +137,28 @@ function cmdPush(opts = {}) {
129
137
  if (skippedTracked.length > 0) {
130
138
  lines.push("", `${skippedTracked.length} project files skipped \u2014 git already tracks them.`);
131
139
  }
140
+ const excluded = describeExcluded(root, tiers);
141
+ if (excluded.length > 0) {
142
+ lines.push("", "Not included (by design):");
143
+ for (const g of excluded) {
144
+ lines.push(` ${g.label.padEnd(22)} ${formatBytes(g.bytes).padStart(9)} ${g.reason}`);
145
+ }
146
+ }
132
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
+ }
133
162
  lines.push("", "Commit .contextsync/ to carry this context with the repo.");
134
163
  }
135
164
  return ok(lines);
@@ -159,12 +188,108 @@ function cmdPull(opts = {}) {
159
188
  "Re-run with --force to overwrite them."
160
189
  );
161
190
  }
162
- const handoff = store.readHandoff();
163
- if (handoff) {
164
- 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);
165
205
  }
206
+ lines.push("", ...formatHandoff(check.handoff));
166
207
  return ok(lines);
167
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
+ }
168
293
  function cmdStatus() {
169
294
  const found = requireProject();
170
295
  if ("code" in found) return found;
@@ -173,10 +298,14 @@ function cmdStatus() {
173
298
  const manifest = store.readManifest();
174
299
  const { tiers } = effectiveTiers(cfg);
175
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)";
176
304
  const lines = [
177
305
  `Project ${cfg.name}`,
178
306
  `Store ${path.relative(root, storeDir(root))}/`,
179
307
  `Tiers ${cfg.tiers.join(", ")}`,
308
+ `Handoff ${handoffLabel}`,
180
309
  `Remotes ${Object.keys(cfg.remotes).length > 0 ? Object.keys(cfg.remotes).join(", ") : "none (local only)"}`,
181
310
  ""
182
311
  ];
@@ -218,13 +347,16 @@ function cmdStatus() {
218
347
  }
219
348
 
220
349
  // src/cli.ts
350
+ var VERSION = "0.2.0";
221
351
  var USAGE = `tricknowtech context-sync \u2014 carry a project's LLM context between machines
222
352
 
223
353
  Usage
224
354
  ctx init [--artifacts] [--force] Create the store and install /context
225
355
  ctx push [--dry-run] Collect context into the store
226
- ctx pull [--force] Restore context from the store
227
- 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
228
360
 
229
361
  Options
230
362
  --artifacts Include derived indexes (graphify-out/, etc.)
@@ -235,20 +367,38 @@ Options
235
367
  -v, --version Show version
236
368
 
237
369
  The store lives in .contextsync/ and is meant to be committed, so context
238
- 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.`;
239
374
  function parseArgs(argv) {
240
375
  const flags = /* @__PURE__ */ new Set();
376
+ const values = /* @__PURE__ */ new Map();
241
377
  let command = "";
242
- for (const arg of argv) {
243
- if (arg.startsWith("-")) flags.add(arg.replace(/^-+/, ""));
244
- 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
+ }
245
395
  }
246
- return { command, flags };
396
+ return { command, flags, values };
247
397
  }
248
398
  function run(argv) {
249
- const { command, flags } = parseArgs(argv);
399
+ const { command, flags, values } = parseArgs(argv);
250
400
  if (flags.has("h") || flags.has("help")) return { code: 0, lines: [USAGE] };
251
- if (flags.has("v") || flags.has("version")) return { code: 0, lines: ["0.1.0"] };
401
+ if (flags.has("v") || flags.has("version")) return { code: 0, lines: [VERSION] };
252
402
  switch (command) {
253
403
  case "init":
254
404
  return cmdInit({ artifacts: flags.has("artifacts"), force: flags.has("force") });
@@ -256,6 +406,10 @@ function run(argv) {
256
406
  return cmdPush({ allowSecrets: flags.has("allow-secrets"), dryRun: flags.has("dry-run") });
257
407
  case "pull":
258
408
  return cmdPull({ force: flags.has("force") });
409
+ case "handoff":
410
+ return cmdHandoff({ set: values.get("set") });
411
+ case "doctor":
412
+ return cmdDoctor();
259
413
  case "status":
260
414
  case "":
261
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);
@@ -299,12 +304,41 @@ var PROJECT_CONTEXT_GLOBS = [
299
304
  ".cursorrules",
300
305
  ".github/copilot-instructions.md",
301
306
  ".claude/settings.json",
307
+ // `.local.json` variants are gitignored by default, so nothing else carries
308
+ // them — which makes them exactly the kind of file this tool exists for.
309
+ ".claude/settings.local.json",
302
310
  ".claude/memory/",
303
311
  ".claude/plans/",
304
312
  ".claude/commands/",
305
313
  ".claude/agents/",
306
314
  ".claude/skills/"
307
315
  ];
316
+ function planStem(fileName) {
317
+ return fileName.replace(/\.md$/, "").replace(/-agent-[0-9a-f]+$/i, "");
318
+ }
319
+ function collectPlans(plansDir, projectRoot, exclude) {
320
+ const all = walk(plansDir, { exclude });
321
+ if (all.length === 0) return [];
322
+ const projectName = import_node_path3.default.basename(projectRoot);
323
+ const related = /* @__PURE__ */ new Set();
324
+ const stems = /* @__PURE__ */ new Set();
325
+ for (const abs of all) {
326
+ let text = "";
327
+ try {
328
+ text = import_node_fs3.default.readFileSync(abs, "utf8");
329
+ } catch {
330
+ continue;
331
+ }
332
+ if (text.includes(projectRoot) || text.includes(projectName)) {
333
+ related.add(abs);
334
+ stems.add(planStem(import_node_path3.default.basename(abs)));
335
+ }
336
+ }
337
+ for (const abs of all) {
338
+ if (stems.has(planStem(import_node_path3.default.basename(abs)))) related.add(abs);
339
+ }
340
+ return [...related];
341
+ }
308
342
  function push(out, sourcePath, storePath, tier, sourceRoot, ctx) {
309
343
  let size = 0;
310
344
  try {
@@ -339,10 +373,24 @@ function collect(projectRoot, cfg, tiers) {
339
373
  }
340
374
  push(files, abs, `project/${rel}`, "core", "project", ctx);
341
375
  }
342
- const memoryDir = import_node_path3.default.join(userClaude, "projects", key, "memory");
343
- for (const abs of walk(memoryDir, { exclude })) {
344
- const rel = toPosix(import_node_path3.default.relative(memoryDir, abs));
345
- push(files, abs, `memory/${rel}`, "core", "user", ctx);
376
+ const projectsDir = import_node_path3.default.join(userClaude, "projects");
377
+ let projectKeys = [];
378
+ try {
379
+ projectKeys = import_node_fs3.default.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === key || d.name.startsWith(key + "-"))).map((d) => d.name);
380
+ } catch {
381
+ projectKeys = [];
382
+ }
383
+ for (const pk of projectKeys) {
384
+ const memoryDir = import_node_path3.default.join(projectsDir, pk, "memory");
385
+ for (const abs of walk(memoryDir, { exclude })) {
386
+ const rel = toPosix(import_node_path3.default.relative(memoryDir, abs));
387
+ const storePath = pk === key ? `memory/${rel}` : `memory-sub/${pk.slice(key.length + 1)}/${rel}`;
388
+ push(files, abs, storePath, "core", "user", ctx);
389
+ }
390
+ }
391
+ for (const abs of collectPlans(import_node_path3.default.join(userClaude, "plans"), projectRoot, exclude)) {
392
+ const rel = toPosix(import_node_path3.default.relative(import_node_path3.default.join(userClaude, "plans"), abs));
393
+ push(files, abs, `plans/${rel}`, "core", "user", ctx);
346
394
  }
347
395
  const skillsDir = import_node_path3.default.join(userClaude, "skills");
348
396
  for (const abs of walk(skillsDir, { exclude })) {
@@ -378,6 +426,44 @@ function collect(projectRoot, cfg, tiers) {
378
426
  }
379
427
  return { files, skippedTracked, ctx };
380
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
+ }
381
467
  function summarize(files) {
382
468
  const empty = { count: 0, bytes: 0 };
383
469
  const out = {
@@ -393,6 +479,86 @@ function summarize(files) {
393
479
  return out;
394
480
  }
395
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
+
396
562
  // src/scaffold.ts
397
563
  var import_node_fs4 = __toESM(require("fs"), 1);
398
564
  var import_node_path4 = __toESM(require("path"), 1);
@@ -671,13 +837,17 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
671
837
  configPath,
672
838
  cwdKey,
673
839
  defaultConfig,
840
+ describeExcluded,
674
841
  ensureGitignoreEntries,
675
842
  findProjectRoot,
676
843
  formatBytes,
844
+ formatHandoff,
677
845
  formatHits,
678
846
  gitTrackedSet,
847
+ handoffAge,
679
848
  installSlashCommand,
680
849
  isGitRepo,
850
+ isStale,
681
851
  loadConfig,
682
852
  makeTemplate,
683
853
  matchesAny,
@@ -687,5 +857,6 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
687
857
  storeDir,
688
858
  summarize,
689
859
  userClaudeDir,
860
+ validateHandoff,
690
861
  walk
691
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-DEH5MUFT.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
  };