speclock 1.1.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.
@@ -0,0 +1,110 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { spawnSync } from "child_process";
4
+
5
+ // Safe git command execution — uses spawnSync with args array (no shell injection)
6
+ export function safeGit(root, args) {
7
+ try {
8
+ const res = spawnSync("git", args, {
9
+ cwd: root,
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ timeout: 10000,
12
+ });
13
+ if (res.status !== 0) {
14
+ const stderr = res.stderr ? String(res.stderr).trim() : "git error";
15
+ return { ok: false, stdout: "", stderr };
16
+ }
17
+ return { ok: true, stdout: String(res.stdout).trim(), stderr: "" };
18
+ } catch (e) {
19
+ return { ok: false, stdout: "", stderr: e.message || "git error" };
20
+ }
21
+ }
22
+
23
+ export function hasGit(root) {
24
+ const gitDir = path.join(root, ".git");
25
+ if (!fs.existsSync(gitDir)) return false;
26
+ const res = safeGit(root, ["rev-parse", "--is-inside-work-tree"]);
27
+ return res.ok;
28
+ }
29
+
30
+ export function getHead(root) {
31
+ const branch = safeGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
32
+ const commit = safeGit(root, ["rev-parse", "HEAD"]);
33
+ if (!branch.ok || !commit.ok) {
34
+ return { gitBranch: "", gitCommit: "" };
35
+ }
36
+ return {
37
+ gitBranch: branch.stdout,
38
+ gitCommit: commit.stdout,
39
+ };
40
+ }
41
+
42
+ export function getDefaultBranch(root) {
43
+ const res = safeGit(root, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
44
+ if (res.ok) {
45
+ const parts = res.stdout.split("/");
46
+ return parts[parts.length - 1] || "";
47
+ }
48
+ const head = getHead(root);
49
+ return head.gitBranch || "";
50
+ }
51
+
52
+ export function captureDiff(root) {
53
+ const res = safeGit(root, ["diff"]);
54
+ if (!res.ok) return null;
55
+ return res.stdout;
56
+ }
57
+
58
+ export function captureDiffStaged(root) {
59
+ const res = safeGit(root, ["diff", "--cached"]);
60
+ if (!res.ok) return null;
61
+ return res.stdout;
62
+ }
63
+
64
+ // Parse git status --porcelain into structured data
65
+ export function captureStatus(root) {
66
+ const branchRes = safeGit(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
67
+ const commitRes = safeGit(root, ["rev-parse", "--short", "HEAD"]);
68
+ const statusRes = safeGit(root, ["status", "--porcelain"]);
69
+
70
+ const branch = branchRes.ok ? branchRes.stdout : "";
71
+ const commit = commitRes.ok ? commitRes.stdout : "";
72
+ const changedFiles = [];
73
+
74
+ if (statusRes.ok && statusRes.stdout) {
75
+ const lines = statusRes.stdout.split("\n").filter(Boolean);
76
+ for (const line of lines.slice(0, 50)) {
77
+ const status = line.substring(0, 2).trim();
78
+ const file = line.substring(3);
79
+ changedFiles.push({ status, file });
80
+ }
81
+ }
82
+
83
+ return { branch, commit, changedFiles };
84
+ }
85
+
86
+ export function createTag(root, tagName) {
87
+ const res = safeGit(root, ["tag", tagName]);
88
+ if (!res.ok) {
89
+ return { ok: false, tag: "", error: res.stderr };
90
+ }
91
+ return { ok: true, tag: tagName, error: "" };
92
+ }
93
+
94
+ export function getRecentCommits(root, n = 10) {
95
+ const res = safeGit(root, ["log", `--oneline`, `-${n}`]);
96
+ if (!res.ok || !res.stdout) return [];
97
+ return res.stdout.split("\n").filter(Boolean).map((line) => {
98
+ const spaceIdx = line.indexOf(" ");
99
+ return {
100
+ hash: line.substring(0, spaceIdx),
101
+ message: line.substring(spaceIdx + 1),
102
+ };
103
+ });
104
+ }
105
+
106
+ export function getDiffSummary(root) {
107
+ const res = safeGit(root, ["diff", "--stat"]);
108
+ if (!res.ok) return "";
109
+ return res.stdout;
110
+ }
@@ -0,0 +1,186 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+
5
+ export function nowIso() {
6
+ return new Date().toISOString();
7
+ }
8
+
9
+ export function speclockDir(root) {
10
+ return path.join(root, ".speclock");
11
+ }
12
+
13
+ export function ensureSpeclockDirs(root) {
14
+ const base = speclockDir(root);
15
+ fs.mkdirSync(base, { recursive: true });
16
+ fs.mkdirSync(path.join(base, "patches"), { recursive: true });
17
+ fs.mkdirSync(path.join(base, "context"), { recursive: true });
18
+ }
19
+
20
+ export function brainPath(root) {
21
+ return path.join(speclockDir(root), "brain.json");
22
+ }
23
+
24
+ export function eventsPath(root) {
25
+ return path.join(speclockDir(root), "events.log");
26
+ }
27
+
28
+ export function newId(prefix) {
29
+ return `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
30
+ }
31
+
32
+ // Brain v2 factory
33
+ export function makeBrain(root, hasGitRepo, defaultBranch) {
34
+ const createdAt = nowIso();
35
+ const folderName = path.basename(root);
36
+ return {
37
+ version: 2,
38
+ project: {
39
+ id: newId("sl"),
40
+ name: folderName,
41
+ root,
42
+ createdAt,
43
+ updatedAt: createdAt,
44
+ },
45
+ goal: { text: "", updatedAt: createdAt },
46
+ specLock: {
47
+ items: [],
48
+ },
49
+ decisions: [],
50
+ notes: [],
51
+ facts: {
52
+ deploy: {
53
+ provider: "unknown",
54
+ autoDeploy: false,
55
+ branch: "",
56
+ url: "",
57
+ notes: "",
58
+ },
59
+ repo: {
60
+ defaultBranch: defaultBranch || "",
61
+ hasGit: !!hasGitRepo,
62
+ },
63
+ },
64
+ sessions: {
65
+ current: null,
66
+ history: [],
67
+ },
68
+ state: {
69
+ head: {
70
+ gitBranch: "",
71
+ gitCommit: "",
72
+ capturedAt: createdAt,
73
+ },
74
+ recentChanges: [],
75
+ reverts: [],
76
+ },
77
+ events: { lastEventId: "", count: 0 },
78
+ };
79
+ }
80
+
81
+ // Migrate v1 brain to v2
82
+ export function migrateBrainV1toV2(brain) {
83
+ if (brain.version >= 2) return brain;
84
+
85
+ // Add notes array
86
+ if (!brain.notes) {
87
+ brain.notes = [];
88
+ }
89
+
90
+ // Add sessions
91
+ if (!brain.sessions) {
92
+ brain.sessions = { current: null, history: [] };
93
+ }
94
+
95
+ // Add active flag to all existing locks
96
+ if (brain.specLock && brain.specLock.items) {
97
+ for (const lock of brain.specLock.items) {
98
+ if (lock.active === undefined) lock.active = true;
99
+ }
100
+ }
101
+
102
+ // Add deploy.url
103
+ if (brain.facts && brain.facts.deploy && brain.facts.deploy.url === undefined) {
104
+ brain.facts.deploy.url = "";
105
+ }
106
+
107
+ // Remove old importance field
108
+ delete brain.importance;
109
+
110
+ brain.version = 2;
111
+ return brain;
112
+ }
113
+
114
+ export function readBrain(root) {
115
+ const p = brainPath(root);
116
+ if (!fs.existsSync(p)) return null;
117
+ const raw = fs.readFileSync(p, "utf8");
118
+ let brain = JSON.parse(raw);
119
+ if (brain.version < 2) {
120
+ brain = migrateBrainV1toV2(brain);
121
+ writeBrain(root, brain);
122
+ }
123
+ return brain;
124
+ }
125
+
126
+ export function writeBrain(root, brain) {
127
+ brain.project.updatedAt = nowIso();
128
+ const p = brainPath(root);
129
+ fs.writeFileSync(p, JSON.stringify(brain, null, 2));
130
+ }
131
+
132
+ export function appendEvent(root, event) {
133
+ const line = JSON.stringify(event);
134
+ fs.appendFileSync(eventsPath(root), `${line}\n`);
135
+ }
136
+
137
+ // Read events.log with optional filtering
138
+ export function readEvents(root, opts = {}) {
139
+ const p = eventsPath(root);
140
+ if (!fs.existsSync(p)) return [];
141
+
142
+ const raw = fs.readFileSync(p, "utf8").trim();
143
+ if (!raw) return [];
144
+
145
+ let events = raw.split("\n").map((line) => {
146
+ try {
147
+ return JSON.parse(line);
148
+ } catch {
149
+ return null;
150
+ }
151
+ }).filter(Boolean);
152
+
153
+ // Filter by type
154
+ if (opts.type) {
155
+ events = events.filter((e) => e.type === opts.type);
156
+ }
157
+
158
+ // Filter by since (ISO timestamp)
159
+ if (opts.since) {
160
+ events = events.filter((e) => e.at >= opts.since);
161
+ }
162
+
163
+ // Return most recent first, apply limit
164
+ events.reverse();
165
+ if (opts.limit && opts.limit > 0) {
166
+ events = events.slice(0, opts.limit);
167
+ }
168
+
169
+ return events;
170
+ }
171
+
172
+ export function bumpEvents(brain, eventId) {
173
+ brain.events.lastEventId = eventId;
174
+ brain.events.count += 1;
175
+ }
176
+
177
+ export function addRecentChange(brain, item) {
178
+ brain.state.recentChanges.unshift(item);
179
+ if (brain.state.recentChanges.length > 20) {
180
+ brain.state.recentChanges = brain.state.recentChanges.slice(0, 20);
181
+ }
182
+ }
183
+
184
+ export function addRevert(brain, item) {
185
+ brain.state.reverts.unshift(item);
186
+ }