@tricknowtech/context 0.1.1 → 0.3.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/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,17 +65,18 @@ __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);
67
72
 
68
73
  // src/collector.ts
69
- var import_node_fs3 = __toESM(require("fs"), 1);
70
- var import_node_path3 = __toESM(require("path"), 1);
74
+ var import_node_fs4 = __toESM(require("fs"), 1);
75
+ var import_node_path4 = __toESM(require("path"), 1);
71
76
 
72
- // src/config.ts
73
- var import_node_crypto = __toESM(require("crypto"), 1);
77
+ // src/assistants.ts
74
78
  var import_node_fs2 = __toESM(require("fs"), 1);
79
+ var import_node_os2 = __toESM(require("os"), 1);
75
80
  var import_node_path2 = __toESM(require("path"), 1);
76
81
 
77
82
  // src/fsutil.ts
@@ -88,8 +93,9 @@ function cwdKey(absPath) {
88
93
  function makeTemplate(absPath, ctx) {
89
94
  const candidates = [
90
95
  [ctx.userClaude, "{userClaude}"],
91
- [ctx.project, "{project}"]
92
- ].sort((a, b) => b[0].length - a[0].length);
96
+ [ctx.project, "{project}"],
97
+ [ctx.home, "{home}"]
98
+ ].filter(([root]) => Boolean(root)).sort((a, b) => b[0].length - a[0].length);
93
99
  let out = absPath;
94
100
  for (const [root, token] of candidates) {
95
101
  if (absPath === root || absPath.startsWith(root + import_node_path.default.sep)) {
@@ -100,7 +106,7 @@ function makeTemplate(absPath, ctx) {
100
106
  return ctx.cwdKey ? out.split(ctx.cwdKey).join("{cwdKey}") : out;
101
107
  }
102
108
  function resolveTemplate(template, ctx) {
103
- const expanded = template.split("{userClaude}").join(ctx.userClaude).split("{project}").join(ctx.project).split("{cwdKey}").join(ctx.cwdKey);
109
+ const expanded = template.split("{userClaude}").join(ctx.userClaude).split("{project}").join(ctx.project).split("{home}").join(ctx.home).split("{cwdKey}").join(ctx.cwdKey);
104
110
  return import_node_path.default.normalize(expanded);
105
111
  }
106
112
  function toPosix(p) {
@@ -222,7 +228,176 @@ function formatBytes(n) {
222
228
  return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
223
229
  }
224
230
 
231
+ // src/assistants.ts
232
+ function filesIn(dir, exclude, prefix) {
233
+ return walk(dir, { exclude }).map((abs) => ({
234
+ abs,
235
+ rel: `${prefix}/${toPosix(import_node_path2.default.relative(dir, abs))}`
236
+ }));
237
+ }
238
+ function fileIfExists(abs, rel) {
239
+ return import_node_fs2.default.existsSync(abs) ? [{ abs, rel }] : [];
240
+ }
241
+ function planStem(fileName) {
242
+ return fileName.replace(/\.md$/, "").replace(/-agent-[0-9a-f]+$/i, "");
243
+ }
244
+ function relatedByProject(dir, projectRoot, exclude) {
245
+ const all = walk(dir, { exclude });
246
+ if (all.length === 0) return [];
247
+ const projectName = import_node_path2.default.basename(projectRoot);
248
+ const related = /* @__PURE__ */ new Set();
249
+ const stems = /* @__PURE__ */ new Set();
250
+ for (const abs of all) {
251
+ let text = "";
252
+ try {
253
+ text = import_node_fs2.default.readFileSync(abs, "utf8");
254
+ } catch {
255
+ continue;
256
+ }
257
+ if (text.includes(projectRoot) || text.includes(projectName)) {
258
+ related.add(abs);
259
+ stems.add(planStem(import_node_path2.default.basename(abs)));
260
+ }
261
+ }
262
+ for (const abs of all) {
263
+ if (stems.has(planStem(import_node_path2.default.basename(abs)))) related.add(abs);
264
+ }
265
+ return [...related];
266
+ }
267
+ var ADAPTERS = [
268
+ {
269
+ id: "claude",
270
+ name: "Claude Code",
271
+ projectGlobs: [
272
+ "CLAUDE.md",
273
+ "CLAUDE.local.md",
274
+ "**/CLAUDE.md",
275
+ ".claude/settings.json",
276
+ ".claude/settings.local.json",
277
+ ".claude/memory/",
278
+ ".claude/plans/",
279
+ ".claude/commands/",
280
+ ".claude/agents/",
281
+ ".claude/skills/"
282
+ ],
283
+ userDir: () => userClaudeDir(),
284
+ collectUser: (projectRoot, _home, exclude) => {
285
+ const root = userClaudeDir();
286
+ const out = [];
287
+ const key = cwdKey(projectRoot);
288
+ const projectsDir = import_node_path2.default.join(root, "projects");
289
+ let keys = [];
290
+ try {
291
+ keys = import_node_fs2.default.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === key || d.name.startsWith(key + "-"))).map((d) => d.name);
292
+ } catch {
293
+ keys = [];
294
+ }
295
+ for (const k of keys) {
296
+ const memDir = import_node_path2.default.join(projectsDir, k, "memory");
297
+ const prefix = k === key ? "memory" : `memory-sub/${k.slice(key.length + 1)}`;
298
+ out.push(...filesIn(memDir, exclude, prefix));
299
+ }
300
+ out.push(...filesIn(import_node_path2.default.join(root, "skills"), exclude, "skills"));
301
+ out.push(...filesIn(import_node_path2.default.join(root, "agents"), exclude, "agents"));
302
+ for (const abs of relatedByProject(import_node_path2.default.join(root, "plans"), projectRoot, exclude)) {
303
+ out.push({ abs, rel: `plans/${import_node_path2.default.basename(abs)}` });
304
+ }
305
+ out.push(...fileIfExists(import_node_path2.default.join(root, "CLAUDE.md"), "user/CLAUDE.md"));
306
+ out.push(...fileIfExists(import_node_path2.default.join(root, "settings.json"), "user/settings.json"));
307
+ return out;
308
+ }
309
+ },
310
+ {
311
+ id: "codex",
312
+ name: "OpenAI Codex",
313
+ projectGlobs: ["AGENTS.md", "**/AGENTS.md", ".codex/"],
314
+ userDir: (home) => import_node_path2.default.join(home, ".codex"),
315
+ collectUser: (projectRoot, home, exclude) => {
316
+ const root = import_node_path2.default.join(home, ".codex");
317
+ const out = [];
318
+ out.push(...fileIfExists(import_node_path2.default.join(root, "AGENTS.md"), "user/AGENTS.md"));
319
+ out.push(...fileIfExists(import_node_path2.default.join(root, "config.toml"), "user/config.toml"));
320
+ out.push(...filesIn(import_node_path2.default.join(root, "prompts"), exclude, "prompts"));
321
+ return out;
322
+ }
323
+ },
324
+ {
325
+ id: "cursor",
326
+ name: "Cursor",
327
+ // `.cursorrules` is the legacy single-file form; `.cursor/rules/*.mdc` is current.
328
+ projectGlobs: [".cursorrules", ".cursor/rules/", ".cursor/"],
329
+ userDir: (home) => import_node_path2.default.join(home, ".cursor"),
330
+ collectUser: (_projectRoot, home, exclude) => filesIn(import_node_path2.default.join(home, ".cursor", "rules"), exclude, "rules")
331
+ },
332
+ {
333
+ id: "copilot",
334
+ name: "GitHub Copilot",
335
+ projectGlobs: [
336
+ ".github/copilot-instructions.md",
337
+ ".github/instructions/",
338
+ ".github/prompts/"
339
+ ]
340
+ },
341
+ {
342
+ id: "windsurf",
343
+ name: "Windsurf",
344
+ projectGlobs: [".windsurfrules", ".windsurf/rules/", ".windsurf/"],
345
+ userDir: (home) => import_node_path2.default.join(home, ".windsurf")
346
+ },
347
+ {
348
+ id: "gemini",
349
+ name: "Gemini CLI",
350
+ projectGlobs: ["GEMINI.md", "**/GEMINI.md", ".gemini/"],
351
+ userDir: (home) => import_node_path2.default.join(home, ".gemini"),
352
+ collectUser: (_projectRoot, home, exclude) => {
353
+ const root = import_node_path2.default.join(home, ".gemini");
354
+ const out = [];
355
+ out.push(...fileIfExists(import_node_path2.default.join(root, "GEMINI.md"), "user/GEMINI.md"));
356
+ out.push(...fileIfExists(import_node_path2.default.join(root, "settings.json"), "user/settings.json"));
357
+ out.push(...filesIn(import_node_path2.default.join(root, "commands"), exclude, "commands"));
358
+ return out;
359
+ }
360
+ },
361
+ {
362
+ id: "cline",
363
+ name: "Cline",
364
+ projectGlobs: [".clinerules", ".clinerules/"]
365
+ },
366
+ {
367
+ id: "aider",
368
+ name: "Aider",
369
+ projectGlobs: ["CONVENTIONS.md", ".aider.conf.yml", ".aider.conf.yaml"]
370
+ },
371
+ {
372
+ id: "continue",
373
+ name: "Continue",
374
+ projectGlobs: [".continue/", ".continuerules"],
375
+ userDir: (home) => import_node_path2.default.join(home, ".continue")
376
+ }
377
+ ];
378
+ function adapterById(id) {
379
+ return ADAPTERS.find((a) => a.id === id);
380
+ }
381
+ function homeDir() {
382
+ return import_node_os2.default.homedir();
383
+ }
384
+ function detectAssistants(projectRoot, projectFiles) {
385
+ const home = homeDir();
386
+ return ADAPTERS.filter((a) => {
387
+ if (a.userDir) {
388
+ try {
389
+ if (import_node_fs2.default.existsSync(a.userDir(home))) return true;
390
+ } catch {
391
+ }
392
+ }
393
+ return projectFiles.some((rel) => matchesAny(rel, a.projectGlobs));
394
+ });
395
+ }
396
+
225
397
  // src/config.ts
398
+ var import_node_crypto = __toESM(require("crypto"), 1);
399
+ var import_node_fs3 = __toESM(require("fs"), 1);
400
+ var import_node_path3 = __toESM(require("path"), 1);
226
401
  var STORE_DIR = ".contextsync";
227
402
  var CONFIG_FILE = "config.json";
228
403
  var HANDOFF_FILE = "handoff.json";
@@ -251,29 +426,30 @@ var DEFAULT_EXCLUDE = [
251
426
  function defaultConfig(projectRoot) {
252
427
  return {
253
428
  projectId: import_node_crypto.default.randomUUID(),
254
- name: import_node_path2.default.basename(projectRoot),
429
+ name: import_node_path3.default.basename(projectRoot),
255
430
  rootHint: projectRoot,
256
431
  tiers: ["core", "handoff"],
432
+ assistants: ["auto"],
257
433
  artifactPaths: ["graphify-out"],
258
434
  exclude: [...DEFAULT_EXCLUDE],
259
435
  remotes: {}
260
436
  };
261
437
  }
262
438
  function findProjectRoot(start = process.cwd()) {
263
- let dir = import_node_path2.default.resolve(start);
439
+ let dir = import_node_path3.default.resolve(start);
264
440
  for (; ; ) {
265
- if (import_node_fs2.default.existsSync(import_node_path2.default.join(dir, STORE_DIR, CONFIG_FILE))) return dir;
266
- if (import_node_fs2.default.existsSync(import_node_path2.default.join(dir, ".git"))) return dir;
267
- const parent = import_node_path2.default.dirname(dir);
441
+ if (import_node_fs3.default.existsSync(import_node_path3.default.join(dir, STORE_DIR, CONFIG_FILE))) return dir;
442
+ if (import_node_fs3.default.existsSync(import_node_path3.default.join(dir, ".git"))) return dir;
443
+ const parent = import_node_path3.default.dirname(dir);
268
444
  if (parent === dir) return null;
269
445
  dir = parent;
270
446
  }
271
447
  }
272
448
  function storeDir(projectRoot) {
273
- return import_node_path2.default.join(projectRoot, STORE_DIR);
449
+ return import_node_path3.default.join(projectRoot, STORE_DIR);
274
450
  }
275
451
  function configPath(projectRoot) {
276
- return import_node_path2.default.join(storeDir(projectRoot), CONFIG_FILE);
452
+ return import_node_path3.default.join(storeDir(projectRoot), CONFIG_FILE);
277
453
  }
278
454
  function loadConfig(projectRoot) {
279
455
  const cfg = readJson(configPath(projectRoot));
@@ -282,7 +458,9 @@ function loadConfig(projectRoot) {
282
458
  ...defaultConfig(projectRoot),
283
459
  ...cfg,
284
460
  remotes: cfg.remotes ?? {},
285
- tiers: cfg.tiers ?? ["core", "handoff"]
461
+ tiers: cfg.tiers ?? ["core", "handoff"],
462
+ // Stores written before multi-assistant support have no `assistants` key.
463
+ assistants: cfg.assistants ?? ["auto"]
286
464
  };
287
465
  }
288
466
  function saveConfig(projectRoot, cfg) {
@@ -290,54 +468,12 @@ function saveConfig(projectRoot, cfg) {
290
468
  }
291
469
 
292
470
  // src/collector.ts
293
- var PROJECT_CONTEXT_GLOBS = [
294
- "CLAUDE.md",
295
- "CLAUDE.local.md",
296
- "AGENTS.md",
297
- "**/CLAUDE.md",
298
- "**/AGENTS.md",
299
- ".cursorrules",
300
- ".github/copilot-instructions.md",
301
- ".claude/settings.json",
302
- // `.local.json` variants are gitignored by default, so nothing else carries
303
- // them — which makes them exactly the kind of file this tool exists for.
304
- ".claude/settings.local.json",
305
- ".claude/memory/",
306
- ".claude/plans/",
307
- ".claude/commands/",
308
- ".claude/agents/",
309
- ".claude/skills/"
310
- ];
311
- function planStem(fileName) {
312
- return fileName.replace(/\.md$/, "").replace(/-agent-[0-9a-f]+$/i, "");
313
- }
314
- function collectPlans(plansDir, projectRoot, exclude) {
315
- const all = walk(plansDir, { exclude });
316
- if (all.length === 0) return [];
317
- const projectName = import_node_path3.default.basename(projectRoot);
318
- const related = /* @__PURE__ */ new Set();
319
- const stems = /* @__PURE__ */ new Set();
320
- for (const abs of all) {
321
- let text = "";
322
- try {
323
- text = import_node_fs3.default.readFileSync(abs, "utf8");
324
- } catch {
325
- continue;
326
- }
327
- if (text.includes(projectRoot) || text.includes(projectName)) {
328
- related.add(abs);
329
- stems.add(planStem(import_node_path3.default.basename(abs)));
330
- }
331
- }
332
- for (const abs of all) {
333
- if (stems.has(planStem(import_node_path3.default.basename(abs)))) related.add(abs);
334
- }
335
- return [...related];
336
- }
337
471
  function push(out, sourcePath, storePath, tier, sourceRoot, ctx) {
338
472
  let size = 0;
339
473
  try {
340
- size = import_node_fs3.default.statSync(sourcePath).size;
474
+ const st = import_node_fs4.default.statSync(sourcePath);
475
+ if (!st.isFile()) return;
476
+ size = st.size;
341
477
  } catch {
342
478
  return;
343
479
  }
@@ -350,76 +486,106 @@ function push(out, sourcePath, storePath, tier, sourceRoot, ctx) {
350
486
  restoreTemplate: makeTemplate(sourcePath, ctx)
351
487
  });
352
488
  }
489
+ function templateContextFor(projectRoot) {
490
+ return {
491
+ userClaude: userClaudeDir(),
492
+ project: projectRoot,
493
+ cwdKey: cwdKey(projectRoot),
494
+ home: homeDir()
495
+ };
496
+ }
353
497
  function collect(projectRoot, cfg, tiers) {
354
- const userClaude = userClaudeDir();
355
- const key = cwdKey(projectRoot);
356
- const ctx = { userClaude, project: projectRoot, cwdKey: key };
498
+ const ctx = templateContextFor(projectRoot);
499
+ const home = ctx.home;
357
500
  const files = [];
358
501
  const skippedTracked = [];
359
502
  const exclude = [...HARD_DENY, ...cfg.exclude];
360
503
  const tracked = gitTrackedSet(projectRoot);
504
+ const projectRel = walk(projectRoot, { exclude }).map((abs) => ({
505
+ abs,
506
+ rel: toPosix(import_node_path4.default.relative(projectRoot, abs))
507
+ }));
508
+ const configured = cfg.assistants && cfg.assistants.length > 0 && !cfg.assistants.includes("auto");
509
+ const assistants = configured ? cfg.assistants.map(adapterById).filter(Boolean) : detectAssistants(projectRoot, projectRel.map((p) => p.rel));
361
510
  if (tiers.includes("core")) {
362
- for (const abs of walk(projectRoot, { exclude })) {
363
- const rel = toPosix(import_node_path3.default.relative(projectRoot, abs));
364
- if (!matchesAny(rel, PROJECT_CONTEXT_GLOBS)) continue;
365
- if (tracked.has(abs)) {
366
- skippedTracked.push(rel);
367
- continue;
511
+ const claimed = /* @__PURE__ */ new Set();
512
+ for (const adapter of assistants) {
513
+ for (const { abs, rel } of projectRel) {
514
+ if (claimed.has(rel)) continue;
515
+ if (!matchesAny(rel, adapter.projectGlobs)) continue;
516
+ claimed.add(rel);
517
+ if (tracked.has(abs)) {
518
+ skippedTracked.push(rel);
519
+ continue;
520
+ }
521
+ push(files, abs, `project/${rel}`, "core", "project", ctx);
368
522
  }
369
- push(files, abs, `project/${rel}`, "core", "project", ctx);
370
- }
371
- const projectsDir = import_node_path3.default.join(userClaude, "projects");
372
- let projectKeys = [];
373
- try {
374
- projectKeys = import_node_fs3.default.readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === key || d.name.startsWith(key + "-"))).map((d) => d.name);
375
- } catch {
376
- projectKeys = [];
377
523
  }
378
- for (const pk of projectKeys) {
379
- const memoryDir = import_node_path3.default.join(projectsDir, pk, "memory");
380
- for (const abs of walk(memoryDir, { exclude })) {
381
- const rel = toPosix(import_node_path3.default.relative(memoryDir, abs));
382
- const storePath = pk === key ? `memory/${rel}` : `memory-sub/${pk.slice(key.length + 1)}/${rel}`;
383
- push(files, abs, storePath, "core", "user", ctx);
524
+ for (const adapter of assistants) {
525
+ if (!adapter.collectUser) continue;
526
+ for (const { abs, rel } of adapter.collectUser(projectRoot, home, exclude)) {
527
+ push(files, abs, `assistants/${adapter.id}/${rel}`, "core", "user", ctx);
384
528
  }
385
529
  }
386
- for (const abs of collectPlans(import_node_path3.default.join(userClaude, "plans"), projectRoot, exclude)) {
387
- const rel = toPosix(import_node_path3.default.relative(import_node_path3.default.join(userClaude, "plans"), abs));
388
- push(files, abs, `plans/${rel}`, "core", "user", ctx);
389
- }
390
- const skillsDir = import_node_path3.default.join(userClaude, "skills");
391
- for (const abs of walk(skillsDir, { exclude })) {
392
- const rel = toPosix(import_node_path3.default.relative(skillsDir, abs));
393
- push(files, abs, `skills/${rel}`, "core", "user", ctx);
394
- }
395
- const agentsDir = import_node_path3.default.join(userClaude, "agents");
396
- for (const abs of walk(agentsDir, { exclude })) {
397
- const rel = toPosix(import_node_path3.default.relative(agentsDir, abs));
398
- push(files, abs, `agents/${rel}`, "core", "user", ctx);
399
- }
400
- for (const name of ["CLAUDE.md", "settings.json"]) {
401
- const abs = import_node_path3.default.join(userClaude, name);
402
- if (import_node_fs3.default.existsSync(abs)) push(files, abs, `user/${name}`, "core", "user", ctx);
403
- }
404
530
  }
405
531
  if (tiers.includes("artifacts")) {
406
532
  for (const relDir of cfg.artifactPaths) {
407
- const absDir = import_node_path3.default.join(projectRoot, relDir);
533
+ const absDir = import_node_path4.default.join(projectRoot, relDir);
408
534
  for (const abs of walk(absDir, { exclude })) {
409
- const rel = toPosix(import_node_path3.default.relative(absDir, abs));
535
+ const rel = toPosix(import_node_path4.default.relative(absDir, abs));
410
536
  push(files, abs, `artifacts/${relDir}/${rel}`, "artifacts", "project", ctx);
411
537
  }
412
538
  }
413
539
  }
414
540
  if (tiers.includes("transcripts")) {
415
- const projDir = import_node_path3.default.join(userClaude, "projects", key);
541
+ const projDir = import_node_path4.default.join(userClaudeDir(), "projects", ctx.cwdKey);
416
542
  for (const abs of walk(projDir, { exclude })) {
417
- const rel = toPosix(import_node_path3.default.relative(projDir, abs));
543
+ const rel = toPosix(import_node_path4.default.relative(projDir, abs));
418
544
  if (rel.startsWith("memory/")) continue;
419
- push(files, abs, `transcripts/${rel}`, "transcripts", "user", ctx);
545
+ push(files, abs, `transcripts/claude/${rel}`, "transcripts", "user", ctx);
546
+ }
547
+ }
548
+ return { files, skippedTracked, assistants, ctx };
549
+ }
550
+ function describeExcluded(projectRoot, tiers) {
551
+ const userClaude = userClaudeDir();
552
+ const key = cwdKey(projectRoot);
553
+ const out = [];
554
+ const measure = (dir, filter) => {
555
+ let files = 0;
556
+ let bytes = 0;
557
+ for (const abs of walk(dir, { exclude: [] })) {
558
+ if (filter && !filter(abs)) continue;
559
+ files++;
560
+ try {
561
+ bytes += import_node_fs4.default.statSync(abs).size;
562
+ } catch {
563
+ }
564
+ }
565
+ return { files, bytes };
566
+ };
567
+ if (!tiers.includes("transcripts")) {
568
+ const m = measure(
569
+ import_node_path4.default.join(userClaude, "projects", key),
570
+ (p) => !p.includes(`${import_node_path4.default.sep}memory${import_node_path4.default.sep}`)
571
+ );
572
+ if (m.files > 0) {
573
+ out.push({
574
+ label: "session transcripts",
575
+ ...m,
576
+ reason: "cloud-only \u2014 append-only logs this large would permanently bloat the repo"
577
+ });
420
578
  }
421
579
  }
422
- return { files, skippedTracked, ctx };
580
+ for (const [dir, label, reason] of [
581
+ ["uploads", "pasted files/images", "session-scoped binaries; regenerate rather than sync"],
582
+ ["file-history", "edit-undo snapshots", "transient local undo state, not portable context"],
583
+ ["tasks", "task outputs", "session-scoped tool output"]
584
+ ]) {
585
+ const m = measure(import_node_path4.default.join(userClaude, dir));
586
+ if (m.files > 0) out.push({ label, ...m, reason });
587
+ }
588
+ return out.filter((g) => g.bytes > 0);
423
589
  }
424
590
  function summarize(files) {
425
591
  const empty = { count: 0, bytes: 0 };
@@ -436,9 +602,89 @@ function summarize(files) {
436
602
  return out;
437
603
  }
438
604
 
605
+ // src/handoff.ts
606
+ function asStringArray(value) {
607
+ if (value === void 0 || value === null) return [];
608
+ if (!Array.isArray(value)) return null;
609
+ if (!value.every((v) => typeof v === "string")) return null;
610
+ return value;
611
+ }
612
+ function validateHandoff(raw) {
613
+ const errors = [];
614
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
615
+ return { ok: false, errors: ["handoff must be a JSON object"] };
616
+ }
617
+ const o = raw;
618
+ const goal = typeof o.goal === "string" ? o.goal.trim() : "";
619
+ const nextStep = typeof o.nextStep === "string" ? o.nextStep.trim() : "";
620
+ if (!goal) errors.push("`goal` is required (what the session set out to do)");
621
+ if (!nextStep) errors.push("`nextStep` is required (the single next action)");
622
+ const decisions = asStringArray(o.decisions);
623
+ const openThreads = asStringArray(o.openThreads);
624
+ const filesTouched = asStringArray(o.filesTouched);
625
+ if (decisions === null) errors.push("`decisions` must be an array of strings");
626
+ if (openThreads === null) errors.push("`openThreads` must be an array of strings");
627
+ if (filesTouched === null) errors.push("`filesTouched` must be an array of strings");
628
+ let updatedAt = typeof o.updatedAt === "string" ? o.updatedAt : "";
629
+ if (!updatedAt || Number.isNaN(Date.parse(updatedAt))) updatedAt = (/* @__PURE__ */ new Date()).toISOString();
630
+ if (errors.length > 0) return { ok: false, errors };
631
+ return {
632
+ ok: true,
633
+ errors: [],
634
+ handoff: {
635
+ updatedAt,
636
+ goal,
637
+ decisions: decisions ?? [],
638
+ openThreads: openThreads ?? [],
639
+ filesTouched: filesTouched ?? [],
640
+ nextStep,
641
+ ...typeof o.notes === "string" && o.notes.trim() ? { notes: o.notes.trim() } : {}
642
+ }
643
+ };
644
+ }
645
+ function ago(iso) {
646
+ const ms = Date.now() - Date.parse(iso);
647
+ if (Number.isNaN(ms)) return "unknown";
648
+ const mins = Math.floor(ms / 6e4);
649
+ if (mins < 1) return "just now";
650
+ if (mins < 60) return `${mins}m ago`;
651
+ const hours = Math.floor(mins / 60);
652
+ if (hours < 24) return `${hours}h ago`;
653
+ return `${Math.floor(hours / 24)}d ago`;
654
+ }
655
+ function formatHandoff(h) {
656
+ const lines = [`Where you left off (${ago(h.updatedAt)})`, "", ` Goal ${h.goal}`, ` Next step ${h.nextStep}`];
657
+ if (h.decisions.length > 0) {
658
+ lines.push("", " Decided:");
659
+ for (const d of h.decisions) lines.push(` \xB7 ${d}`);
660
+ }
661
+ if (h.openThreads.length > 0) {
662
+ lines.push("", " Still open:");
663
+ for (const t of h.openThreads) lines.push(` \xB7 ${t}`);
664
+ }
665
+ if (h.filesTouched.length > 0) {
666
+ const shown = h.filesTouched.slice(0, 12);
667
+ lines.push("", " Files touched:");
668
+ for (const f of shown) lines.push(` ${f}`);
669
+ if (h.filesTouched.length > shown.length) {
670
+ lines.push(` \u2026 and ${h.filesTouched.length - shown.length} more`);
671
+ }
672
+ }
673
+ if (h.notes) lines.push("", ` Notes: ${h.notes}`);
674
+ return lines;
675
+ }
676
+ function handoffAge(h) {
677
+ return ago(h.updatedAt);
678
+ }
679
+ function isStale(h, newestContentMs) {
680
+ const t = Date.parse(h.updatedAt);
681
+ if (Number.isNaN(t)) return true;
682
+ return newestContentMs - t > 60 * 60 * 1e3;
683
+ }
684
+
439
685
  // src/scaffold.ts
440
- var import_node_fs4 = __toESM(require("fs"), 1);
441
- var import_node_path4 = __toESM(require("path"), 1);
686
+ var import_node_fs5 = __toESM(require("fs"), 1);
687
+ var import_node_path5 = __toESM(require("path"), 1);
442
688
  var SLASH_COMMAND_PATH = ".claude/commands/context.md";
443
689
  var SLASH_COMMAND_BODY = `---
444
690
  description: Sync this project's LLM context (memory, skills, instructions, handoff)
@@ -488,26 +734,26 @@ Run the context-sync action requested in: $ARGUMENTS
488
734
  Run: \`npx @tricknowtech/context status\` and summarize the result.
489
735
  `;
490
736
  function installSlashCommand(projectRoot) {
491
- const dest = import_node_path4.default.join(projectRoot, SLASH_COMMAND_PATH);
492
- if (import_node_fs4.default.existsSync(dest)) return { path: SLASH_COMMAND_PATH, written: false };
493
- ensureDir(import_node_path4.default.dirname(dest));
494
- import_node_fs4.default.writeFileSync(dest, SLASH_COMMAND_BODY, "utf8");
737
+ const dest = import_node_path5.default.join(projectRoot, SLASH_COMMAND_PATH);
738
+ if (import_node_fs5.default.existsSync(dest)) return { path: SLASH_COMMAND_PATH, written: false };
739
+ ensureDir(import_node_path5.default.dirname(dest));
740
+ import_node_fs5.default.writeFileSync(dest, SLASH_COMMAND_BODY, "utf8");
495
741
  return { path: SLASH_COMMAND_PATH, written: true };
496
742
  }
497
743
  function ensureGitignoreEntries(projectRoot, artifactsEnabled) {
498
- const gitignore = import_node_path4.default.join(projectRoot, ".gitignore");
744
+ const gitignore = import_node_path5.default.join(projectRoot, ".gitignore");
499
745
  const wanted = [".contextsync/transcripts/"];
500
746
  if (!artifactsEnabled) wanted.push(".contextsync/artifacts/");
501
747
  let existing = "";
502
748
  try {
503
- existing = import_node_fs4.default.readFileSync(gitignore, "utf8");
749
+ existing = import_node_fs5.default.readFileSync(gitignore, "utf8");
504
750
  } catch {
505
751
  }
506
752
  const lines = new Set(existing.split("\n").map((l) => l.trim()));
507
753
  const missing = wanted.filter((w) => !lines.has(w));
508
754
  if (missing.length === 0) return [];
509
755
  const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
510
- import_node_fs4.default.appendFileSync(
756
+ import_node_fs5.default.appendFileSync(
511
757
  gitignore,
512
758
  `${prefix}
513
759
  # tricknowtech context-sync \u2014 never commit these tiers
@@ -519,7 +765,7 @@ ${missing.join("\n")}
519
765
  }
520
766
 
521
767
  // src/secrets.ts
522
- var import_node_fs5 = __toESM(require("fs"), 1);
768
+ var import_node_fs6 = __toESM(require("fs"), 1);
523
769
  var BENIGN_KEY = /(?:^|[_-])(?:input|output|prompt|completion|total|max|min|num|new|cache|cached|remaining|used|count|context|window|budget|estimated?)[_-]?tokens?$|tokens?[_-]?(?:count|used|limit|remaining|in|out|usage|per|budget)$/i;
524
770
  function looksLikeCredential(value) {
525
771
  if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value)) return false;
@@ -573,7 +819,7 @@ function scanFiles(files) {
573
819
  for (const file of files) {
574
820
  let buf;
575
821
  try {
576
- buf = import_node_fs5.default.readFileSync(file.sourcePath);
822
+ buf = import_node_fs6.default.readFileSync(file.sourcePath);
577
823
  } catch {
578
824
  continue;
579
825
  }
@@ -611,8 +857,8 @@ function formatHits(hits) {
611
857
  }
612
858
 
613
859
  // src/store.ts
614
- var import_node_fs6 = __toESM(require("fs"), 1);
615
- var import_node_path5 = __toESM(require("path"), 1);
860
+ var import_node_fs7 = __toESM(require("fs"), 1);
861
+ var import_node_path6 = __toESM(require("path"), 1);
616
862
  var MANIFEST_FILE = "manifest.json";
617
863
  var LocalStore = class {
618
864
  constructor(projectRoot) {
@@ -623,13 +869,13 @@ var LocalStore = class {
623
869
  return storeDir(this.projectRoot);
624
870
  }
625
871
  write(files, projectRoot) {
626
- for (const name of import_node_fs6.default.existsSync(this.dir) ? import_node_fs6.default.readdirSync(this.dir) : []) {
872
+ for (const name of import_node_fs7.default.existsSync(this.dir) ? import_node_fs7.default.readdirSync(this.dir) : []) {
627
873
  if (name === "config.json" || name === HANDOFF_FILE) continue;
628
- import_node_fs6.default.rmSync(import_node_path5.default.join(this.dir, name), { recursive: true, force: true });
874
+ import_node_fs7.default.rmSync(import_node_path6.default.join(this.dir, name), { recursive: true, force: true });
629
875
  }
630
876
  const entries = [];
631
877
  for (const file of files) {
632
- const dest = import_node_path5.default.join(this.dir, file.storePath);
878
+ const dest = import_node_path6.default.join(this.dir, file.storePath);
633
879
  try {
634
880
  copyFile(file.sourcePath, dest);
635
881
  } catch {
@@ -650,11 +896,11 @@ var LocalStore = class {
650
896
  writtenFrom: projectRoot,
651
897
  entries
652
898
  };
653
- writeJson(import_node_path5.default.join(this.dir, MANIFEST_FILE), manifest);
899
+ writeJson(import_node_path6.default.join(this.dir, MANIFEST_FILE), manifest);
654
900
  return manifest;
655
901
  }
656
902
  readManifest() {
657
- return readJson(import_node_path5.default.join(this.dir, MANIFEST_FILE));
903
+ return readJson(import_node_path6.default.join(this.dir, MANIFEST_FILE));
658
904
  }
659
905
  restore(ctx, opts = {}) {
660
906
  const manifest = this.readManifest();
@@ -662,12 +908,12 @@ var LocalStore = class {
662
908
  const skipped = [];
663
909
  if (!manifest) return { restored, skipped };
664
910
  for (const entry of manifest.entries) {
665
- const src = import_node_path5.default.join(this.dir, entry.storePath);
666
- if (!import_node_fs6.default.existsSync(src)) continue;
911
+ const src = import_node_path6.default.join(this.dir, entry.storePath);
912
+ if (!import_node_fs7.default.existsSync(src)) continue;
667
913
  const dest = resolveTemplate(entry.restoreTemplate, ctx);
668
- if (!opts.force && import_node_fs6.default.existsSync(dest)) {
914
+ if (!opts.force && import_node_fs7.default.existsSync(dest)) {
669
915
  try {
670
- if (!import_node_fs6.default.readFileSync(dest).equals(import_node_fs6.default.readFileSync(src))) {
916
+ if (!import_node_fs7.default.readFileSync(dest).equals(import_node_fs7.default.readFileSync(src))) {
671
917
  skipped.push(entry.storePath);
672
918
  continue;
673
919
  }
@@ -677,8 +923,8 @@ var LocalStore = class {
677
923
  }
678
924
  }
679
925
  try {
680
- ensureDir(import_node_path5.default.dirname(dest));
681
- import_node_fs6.default.copyFileSync(src, dest);
926
+ ensureDir(import_node_path6.default.dirname(dest));
927
+ import_node_fs7.default.copyFileSync(src, dest);
682
928
  restored.push(entry.storePath);
683
929
  } catch {
684
930
  skipped.push(entry.storePath);
@@ -687,10 +933,10 @@ var LocalStore = class {
687
933
  return { restored, skipped };
688
934
  }
689
935
  readHandoff() {
690
- return readJson(import_node_path5.default.join(this.dir, HANDOFF_FILE));
936
+ return readJson(import_node_path6.default.join(this.dir, HANDOFF_FILE));
691
937
  }
692
938
  writeHandoff(handoff) {
693
- writeJson(import_node_path5.default.join(this.dir, HANDOFF_FILE), handoff);
939
+ writeJson(import_node_path6.default.join(this.dir, HANDOFF_FILE), handoff);
694
940
  }
695
941
  };
696
942
 
@@ -714,13 +960,17 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
714
960
  configPath,
715
961
  cwdKey,
716
962
  defaultConfig,
963
+ describeExcluded,
717
964
  ensureGitignoreEntries,
718
965
  findProjectRoot,
719
966
  formatBytes,
967
+ formatHandoff,
720
968
  formatHits,
721
969
  gitTrackedSet,
970
+ handoffAge,
722
971
  installSlashCommand,
723
972
  isGitRepo,
973
+ isStale,
724
974
  loadConfig,
725
975
  makeTemplate,
726
976
  matchesAny,
@@ -730,5 +980,6 @@ var LOCAL_TIERS = ["core", "handoff", "artifacts"];
730
980
  storeDir,
731
981
  summarize,
732
982
  userClaudeDir,
983
+ validateHandoff,
733
984
  walk
734
985
  });