@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 +27 -2
- package/dist/{chunk-BWATZKYM.js → chunk-6GB2SN2L.js} +84 -0
- package/dist/cli.cjs +232 -12
- package/dist/cli.js +160 -14
- package/dist/index.cjs +128 -0
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +11 -1
- package/package.json +1 -1
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
|
|
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
|
|
857
|
-
if (
|
|
858
|
-
lines.push(
|
|
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
|
|
921
|
-
ctx status
|
|
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 (
|
|
937
|
-
|
|
938
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
|
171
|
-
if (
|
|
172
|
-
lines.push(
|
|
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
|
|
235
|
-
ctx status
|
|
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 (
|
|
251
|
-
|
|
252
|
-
|
|
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: [
|
|
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-
|
|
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.
|
|
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",
|