@hobocode/thought-layer 0.6.1 → 0.7.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 +3 -2
- package/core/index.ts +3 -0
- package/core/merge.ts +156 -0
- package/core/sync-io.ts +429 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +598 -30
- package/extensions/thought-layer.ts +32 -1
- package/package.json +4 -1
- package/skills/thought-layer-framework/SKILL.md +9 -0
package/core/sync.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Pure helpers for the git-backed session sync: slug a human session name into a
|
|
2
|
+
// filename, model the machine-local sync config (which private repos and clone
|
|
3
|
+
// dirs the user has set up), resolve which clone dir an op targets, and parse
|
|
4
|
+
// `git status` output. No fs and no node deps live here; the actual git/gh shell
|
|
5
|
+
// outs and config read/write are in sync-io.ts.
|
|
6
|
+
//
|
|
7
|
+
// Copy rule for any user-facing string: no em-dashes, no en-dashes, no spaced
|
|
8
|
+
// hyphen dashes.
|
|
9
|
+
|
|
10
|
+
// ---- session naming ----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
// Turn a human session name ("Photo Booth!", "Peptide v2") into a safe file slug
|
|
13
|
+
// ("photo-booth", "peptide-v2"). Lowercase, non [a-z0-9] runs collapse to a
|
|
14
|
+
// single hyphen, trimmed, capped. Returns "" for an empty or all-punctuation
|
|
15
|
+
// name so the caller can reject it (a session must be named, never timestamped).
|
|
16
|
+
export function slugify(name: string): string {
|
|
17
|
+
return String(name || "")
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "")
|
|
21
|
+
.slice(0, 40)
|
|
22
|
+
.replace(/-+$/g, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---- machine-local sync config -----------------------------------------------
|
|
26
|
+
|
|
27
|
+
// A workspace is one (private repo, local clone dir) pair. The user can have
|
|
28
|
+
// several: a personal one for their own projects, and one per outside founder
|
|
29
|
+
// they collaborate with. The config is machine-local and never committed.
|
|
30
|
+
export interface SyncWorkspace {
|
|
31
|
+
name: string; // human label for the workspace (e.g. "personal", "acme-co")
|
|
32
|
+
repo: string; // the GitHub repo, e.g. "hobocode-ofc/tl-sessions" or a URL
|
|
33
|
+
defaultBranch: string; // usually "main"
|
|
34
|
+
cloneDir: string; // absolute path to the local clone
|
|
35
|
+
activeSession?: string; // the <slug>.json last opened in this workspace
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SyncConfig {
|
|
39
|
+
schema: number;
|
|
40
|
+
activeWorkspace?: string; // workspace name selected when no --dir is given
|
|
41
|
+
workspaces: SyncWorkspace[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function emptySyncConfig(): SyncConfig {
|
|
45
|
+
return { schema: 1, workspaces: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const str = (v: unknown, fb = ""): string => (typeof v === "string" ? v : fb);
|
|
49
|
+
|
|
50
|
+
// Defensive parse of a hand-editable sync.json. Drops malformed workspaces
|
|
51
|
+
// (a workspace needs at least a cloneDir); never throws on junk.
|
|
52
|
+
export function parseSyncConfig(text: string): SyncConfig {
|
|
53
|
+
let raw: Record<string, unknown>;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(text) as Record<string, unknown>;
|
|
56
|
+
} catch {
|
|
57
|
+
return emptySyncConfig();
|
|
58
|
+
}
|
|
59
|
+
const list = Array.isArray(raw["workspaces"]) ? (raw["workspaces"] as unknown[]) : [];
|
|
60
|
+
const workspaces: SyncWorkspace[] = [];
|
|
61
|
+
for (const w of list) {
|
|
62
|
+
if (!w || typeof w !== "object") continue;
|
|
63
|
+
const r = w as Record<string, unknown>;
|
|
64
|
+
const cloneDir = str(r["cloneDir"]).trim();
|
|
65
|
+
if (!cloneDir) continue;
|
|
66
|
+
const ws: SyncWorkspace = {
|
|
67
|
+
name: str(r["name"]).trim() || cloneDir,
|
|
68
|
+
repo: str(r["repo"]).trim(),
|
|
69
|
+
defaultBranch: str(r["defaultBranch"]).trim() || "main",
|
|
70
|
+
cloneDir,
|
|
71
|
+
};
|
|
72
|
+
const active = str(r["activeSession"]).trim();
|
|
73
|
+
if (active) ws.activeSession = active;
|
|
74
|
+
workspaces.push(ws);
|
|
75
|
+
}
|
|
76
|
+
const cfg: SyncConfig = { schema: 1, workspaces };
|
|
77
|
+
const aw = str(raw["activeWorkspace"]).trim();
|
|
78
|
+
if (aw) cfg.activeWorkspace = aw;
|
|
79
|
+
return cfg;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function serializeSyncConfig(cfg: SyncConfig): string {
|
|
83
|
+
return JSON.stringify({ schema: 1, activeWorkspace: cfg.activeWorkspace, workspaces: cfg.workspaces }, null, 2) + "\n";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Find a workspace by name, or fall back to the active one, or the only one.
|
|
87
|
+
export function selectWorkspace(cfg: SyncConfig, name?: string): SyncWorkspace | null {
|
|
88
|
+
if (name) return cfg.workspaces.find((w) => w.name === name) || null;
|
|
89
|
+
if (cfg.activeWorkspace) {
|
|
90
|
+
const w = cfg.workspaces.find((ws) => ws.name === cfg.activeWorkspace);
|
|
91
|
+
if (w) return w;
|
|
92
|
+
}
|
|
93
|
+
return cfg.workspaces.length === 1 ? cfg.workspaces[0]! : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The default personal clone dir, under one dot-dir off every product tree so it
|
|
97
|
+
// can never leak into a product repo. Per-client dirs are explicit cloneDirs
|
|
98
|
+
// recorded as workspaces. `home` is passed in to keep this pure.
|
|
99
|
+
export function defaultSessionsDir(home: string): string {
|
|
100
|
+
return `${home}/.thought-layer/sessions`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Resolve the clone dir an op targets. Precedence mirrors resolveStatePath:
|
|
104
|
+
// an explicit --dir wins, then the env var, then the selected workspace, then
|
|
105
|
+
// the personal default. Returns the absolute dir (relative inputs are the
|
|
106
|
+
// caller's responsibility to absolutize).
|
|
107
|
+
export function resolveCloneDir(opts: {
|
|
108
|
+
explicit?: string;
|
|
109
|
+
env?: string;
|
|
110
|
+
workspace?: SyncWorkspace | null;
|
|
111
|
+
home: string;
|
|
112
|
+
}): string {
|
|
113
|
+
if (opts.explicit && opts.explicit.trim()) return opts.explicit.trim();
|
|
114
|
+
if (opts.env && opts.env.trim()) return opts.env.trim();
|
|
115
|
+
if (opts.workspace?.cloneDir) return opts.workspace.cloneDir;
|
|
116
|
+
return defaultSessionsDir(opts.home);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- git status parsing ------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export interface GitStatus {
|
|
122
|
+
branch: string | null;
|
|
123
|
+
ahead: number;
|
|
124
|
+
behind: number;
|
|
125
|
+
dirty: boolean; // any tracked change or untracked file
|
|
126
|
+
files: string[]; // the changed/untracked paths
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Parse `git status --porcelain=v1 --branch`. The first line is a `## ` branch
|
|
130
|
+
// header (with optional "[ahead N, behind M]"); the rest are file entries.
|
|
131
|
+
export function parseGitStatus(out: string): GitStatus {
|
|
132
|
+
const lines = String(out || "").split("\n").filter((l) => l.length > 0);
|
|
133
|
+
let branch: string | null = null;
|
|
134
|
+
let ahead = 0;
|
|
135
|
+
let behind = 0;
|
|
136
|
+
const files: string[] = [];
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (line.startsWith("## ")) {
|
|
139
|
+
const header = line.slice(3);
|
|
140
|
+
branch = header.split(/\.\.\.| /)[0] || null;
|
|
141
|
+
const a = header.match(/ahead (\d+)/);
|
|
142
|
+
const b = header.match(/behind (\d+)/);
|
|
143
|
+
if (a) ahead = Number(a[1]);
|
|
144
|
+
if (b) behind = Number(b[1]);
|
|
145
|
+
} else {
|
|
146
|
+
files.push(line.slice(3).trim());
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { branch, ahead, behind, dirty: files.length > 0, files };
|
|
150
|
+
}
|