codehere 1.0.3 → 2.0.0-beta.1
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/LICENSE +1 -2
- package/README.md +104 -42
- package/bin/codehere.js +93 -0
- package/package.json +20 -64
- package/src/brain.js +311 -0
- package/src/commands.js +494 -0
- package/src/gemma.js +48 -0
- package/src/hooks.js +634 -0
- package/src/signals.js +225 -0
- package/dist/application/use-cases/check-claimed-tests.js +0 -81
- package/dist/application/use-cases/detect-test-weakening.js +0 -237
- package/dist/application/use-cases/rerun-tests.js +0 -329
- package/dist/index.js +0 -111
- package/dist/infrastructure/orchestration/artifacts-extractor.js +0 -66
- package/dist/presentation/cli/commands/check-command.js +0 -251
- package/dist/presentation/cli/commands/help-command.js +0 -77
- package/dist/ui.js +0 -53
- package/dist/utils/typo-guard.js +0 -133
- package/dist/utils/version.js +0 -40
package/src/brain.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The team brain: plain markdown files in .codehere/, committed to git.
|
|
2
|
+
// Local, per-machine state lives in .codehere/local/ (git-ignored).
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export function findRoot(start = process.cwd()) {
|
|
9
|
+
let dir = path.resolve(start);
|
|
10
|
+
while (true) {
|
|
11
|
+
if (fs.existsSync(path.join(dir, '.codehere'))) return dir;
|
|
12
|
+
const parent = path.dirname(dir);
|
|
13
|
+
if (parent === dir) return null;
|
|
14
|
+
dir = parent;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function paths(root) {
|
|
19
|
+
const base = path.join(root, '.codehere');
|
|
20
|
+
return {
|
|
21
|
+
base,
|
|
22
|
+
config: path.join(base, 'config.json'),
|
|
23
|
+
rules: path.join(base, 'rules.md'),
|
|
24
|
+
lessons: path.join(base, 'lessons'),
|
|
25
|
+
help: path.join(base, 'help'),
|
|
26
|
+
briefs: path.join(base, 'briefs'),
|
|
27
|
+
local: path.join(base, 'local'),
|
|
28
|
+
state: path.join(base, 'local', 'state.json'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ensureDirs(root) {
|
|
33
|
+
const p = paths(root);
|
|
34
|
+
for (const d of [p.base, p.lessons, p.help, p.briefs, p.local]) {
|
|
35
|
+
fs.mkdirSync(d, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CONFIG = {
|
|
41
|
+
testCommand: null,
|
|
42
|
+
verifyOnStop: true,
|
|
43
|
+
loopThreshold: 3,
|
|
44
|
+
testTimeoutSeconds: 120,
|
|
45
|
+
longSessionTokens: 120000,
|
|
46
|
+
gemmaModel: 'gemma4:e4b',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function readConfig(root) {
|
|
50
|
+
const p = paths(root);
|
|
51
|
+
try {
|
|
52
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(p.config, 'utf8')) };
|
|
53
|
+
} catch {
|
|
54
|
+
return { ...DEFAULT_CONFIG };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeConfig(root, cfg) {
|
|
59
|
+
fs.writeFileSync(paths(root).config, JSON.stringify(cfg, null, 2) + '\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- local session state ----------
|
|
63
|
+
|
|
64
|
+
export function readState(root) {
|
|
65
|
+
try {
|
|
66
|
+
const state = JSON.parse(fs.readFileSync(paths(root).state, 'utf8'));
|
|
67
|
+
// state.json can arrive through git: a file of the wrong shape counts as empty
|
|
68
|
+
if (state && state.sessions && typeof state.sessions === 'object') return state;
|
|
69
|
+
} catch {}
|
|
70
|
+
return { sessions: {}, lastSession: null, lastGreen: null, pendingBrief: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeState(root, state) {
|
|
74
|
+
const p = paths(root);
|
|
75
|
+
fs.mkdirSync(p.local, { recursive: true });
|
|
76
|
+
fs.writeFileSync(p.state, JSON.stringify(state, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function session(state, id) {
|
|
80
|
+
const key = id || 'default';
|
|
81
|
+
if (!state.sessions[key]) {
|
|
82
|
+
state.sessions[key] = {
|
|
83
|
+
errors: {},
|
|
84
|
+
rawErrors: {},
|
|
85
|
+
shownLessons: [],
|
|
86
|
+
stopBlocks: 0,
|
|
87
|
+
lessonAsked: false,
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
transcript: null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
state.lastSession = key;
|
|
93
|
+
state.sessions[key].lastActive = new Date().toISOString();
|
|
94
|
+
// keep the state file small: drop sessions older than the 20 most recent
|
|
95
|
+
const keys = Object.keys(state.sessions);
|
|
96
|
+
if (keys.length > 20) {
|
|
97
|
+
keys
|
|
98
|
+
.sort((a, b) => (state.sessions[a].startedAt || '').localeCompare(state.sessions[b].startedAt || ''))
|
|
99
|
+
.slice(0, keys.length - 20)
|
|
100
|
+
.forEach((k) => delete state.sessions[k]);
|
|
101
|
+
}
|
|
102
|
+
return state.sessions[key];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------- markdown with simple frontmatter ----------
|
|
106
|
+
|
|
107
|
+
export function parseDoc(text) {
|
|
108
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
109
|
+
if (!m) return { meta: {}, body: text };
|
|
110
|
+
const meta = {};
|
|
111
|
+
for (const line of m[1].split('\n')) {
|
|
112
|
+
const i = line.indexOf(':');
|
|
113
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
114
|
+
}
|
|
115
|
+
return { meta, body: m[2] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderDoc(meta, body) {
|
|
119
|
+
const head = Object.entries(meta)
|
|
120
|
+
.map(([k, v]) => `${k}: ${String(v).replace(/\n/g, ' ')}`)
|
|
121
|
+
.join('\n');
|
|
122
|
+
return `---\n${head}\n---\n${body.trimEnd()}\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function listDocs(dir) {
|
|
126
|
+
if (!fs.existsSync(dir)) return [];
|
|
127
|
+
return fs
|
|
128
|
+
.readdirSync(dir)
|
|
129
|
+
.filter((f) => f.endsWith('.md'))
|
|
130
|
+
.sort()
|
|
131
|
+
.map((f) => {
|
|
132
|
+
const file = path.join(dir, f);
|
|
133
|
+
const { meta, body } = parseDoc(fs.readFileSync(file, 'utf8'));
|
|
134
|
+
return { id: f.replace(/\.md$/, ''), file, meta, body };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function slug(text, max = 40) {
|
|
139
|
+
return (
|
|
140
|
+
String(text)
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
143
|
+
.replace(/^-+|-+$/g, '')
|
|
144
|
+
.slice(0, max) || 'note'
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function stamp() {
|
|
149
|
+
const d = new Date();
|
|
150
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
151
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function today() {
|
|
155
|
+
return new Date().toISOString().slice(0, 10);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- rules ----------
|
|
159
|
+
|
|
160
|
+
export function readRules(root) {
|
|
161
|
+
const file = paths(root).rules;
|
|
162
|
+
if (!fs.existsSync(file)) return [];
|
|
163
|
+
return fs
|
|
164
|
+
.readFileSync(file, 'utf8')
|
|
165
|
+
.split('\n')
|
|
166
|
+
.filter((l) => l.startsWith('- '))
|
|
167
|
+
.map((l) => l.slice(2).trim());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------- approved test commands (this machine only) ----------
|
|
171
|
+
// config.json travels through git, so a teammate's commit can change the test
|
|
172
|
+
// command. The finish check runs only a command approved on this machine. The
|
|
173
|
+
// approvals live outside the repo: any file in .codehere/, even a git-ignored one
|
|
174
|
+
// (git add -f), can arrive through a pull.
|
|
175
|
+
|
|
176
|
+
// The real path of p, for a p that may not exist yet.
|
|
177
|
+
function realish(p) {
|
|
178
|
+
try {
|
|
179
|
+
return fs.realpathSync(p);
|
|
180
|
+
} catch {
|
|
181
|
+
const parent = path.dirname(p);
|
|
182
|
+
return parent === p ? p : path.join(realish(parent), path.basename(p));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function repoKey(root) {
|
|
187
|
+
return realish(path.resolve(root));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// null when the file would be inside the repo: settings committed to the repo can set
|
|
191
|
+
// XDG_CONFIG_HOME or HOME for the agent's processes, and so for these hooks.
|
|
192
|
+
function approvalsFile(root) {
|
|
193
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
194
|
+
const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.config');
|
|
195
|
+
const file = path.join(base, 'codehere', 'approvals.json');
|
|
196
|
+
return realish(file).startsWith(repoKey(root) + path.sep) ? null : file;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readAllApprovals(root) {
|
|
200
|
+
try {
|
|
201
|
+
const all = JSON.parse(fs.readFileSync(approvalsFile(root), 'utf8'));
|
|
202
|
+
return all && all.repos && typeof all.repos === 'object' ? all : { repos: {} };
|
|
203
|
+
} catch {
|
|
204
|
+
return { repos: {} };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function readApprovals(root) {
|
|
209
|
+
const list = readAllApprovals(root).repos[repoKey(root)]?.commands;
|
|
210
|
+
return Array.isArray(list) ? list : [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isApproved(root, command) {
|
|
214
|
+
return readApprovals(root).some((a) => a.command === command);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// false when the approvals file would be inside the repo
|
|
218
|
+
export function addApproval(root, command) {
|
|
219
|
+
const file = approvalsFile(root);
|
|
220
|
+
if (!file) return false;
|
|
221
|
+
const all = readAllApprovals(root);
|
|
222
|
+
const list = readApprovals(root).filter((a) => a.command !== command);
|
|
223
|
+
list.push({ command, by: me(root), at: new Date().toISOString() });
|
|
224
|
+
all.repos[repoKey(root)] = { commands: list };
|
|
225
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
226
|
+
fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n');
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Control, format (bidi, zero-width), filler and variation characters escaped, so text from the repo cannot print a fake
|
|
231
|
+
// line, move the cursor or hide part of a command.
|
|
232
|
+
const ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
233
|
+
export function printable(text) {
|
|
234
|
+
return String(text).replace(
|
|
235
|
+
/[\p{Cc}\p{Cf}\u034f\u115f\u1160\u2028\u2029\u3164\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu,
|
|
236
|
+
(c) => {
|
|
237
|
+
const n = c.codePointAt(0).toString(16);
|
|
238
|
+
return ESCAPES[c] || (n.length > 4 ? `\\u{${n}}` : `\\u${n.padStart(4, '0')}`);
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- git helpers (all fail-safe) ----------
|
|
244
|
+
|
|
245
|
+
// Pass an array whenever an argument comes from the repo or the state file: it never
|
|
246
|
+
// goes through a shell, so a file name such as a$(cmd).js stays a file name.
|
|
247
|
+
// raw keeps the output as git wrote it: -z lists, and porcelain lines whose first character is a space.
|
|
248
|
+
export function git(root, args, raw = false) {
|
|
249
|
+
try {
|
|
250
|
+
const opts = { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] };
|
|
251
|
+
const out = (Array.isArray(args) ? execFileSync('git', ['--literal-pathspecs', ...args], opts) : execSync(`git ${args}`, opts)).toString();
|
|
252
|
+
return raw ? out : out.trim();
|
|
253
|
+
} catch {
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Files with uncommitted changes, relative to root, which may be a subfolder of the repo.
|
|
259
|
+
export function statusFiles(root) {
|
|
260
|
+
const prefix = git(root, ['rev-parse', '--show-prefix']);
|
|
261
|
+
const entries = git(root, ['status', '--porcelain', '-z', '--untracked-files=all', '--', '.'], true).split('\0');
|
|
262
|
+
const files = [];
|
|
263
|
+
for (let i = 0; i < entries.length; i++) {
|
|
264
|
+
const e = entries[i];
|
|
265
|
+
if (e.length < 4) continue;
|
|
266
|
+
if (e.slice(3).startsWith(prefix)) files.push(e.slice(3 + prefix.length));
|
|
267
|
+
if (e[0] === 'R' || e[0] === 'C') i++; // the old name of a rename or copy
|
|
268
|
+
}
|
|
269
|
+
return files;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// What `git hash-object` gives for each file (git's filters applied), 'deleted' for a missing
|
|
273
|
+
// file, null when git cannot say. One git call for all of them; absolute paths, because
|
|
274
|
+
// --stdin-paths ignores the subfolder git was started in.
|
|
275
|
+
export function gitHashes(root, files) {
|
|
276
|
+
const present = files.filter((f) => !f.includes('\n') && fs.existsSync(path.join(root, f)));
|
|
277
|
+
let out = [];
|
|
278
|
+
try {
|
|
279
|
+
if (present.length) {
|
|
280
|
+
out = execFileSync('git', ['hash-object', '--stdin-paths'], { cwd: root, input: present.map((f) => path.resolve(root, f)).join('\n') + '\n', stdio: ['pipe', 'pipe', 'ignore'] })
|
|
281
|
+
.toString()
|
|
282
|
+
.trim()
|
|
283
|
+
.split('\n');
|
|
284
|
+
}
|
|
285
|
+
} catch {}
|
|
286
|
+
return files.map((f) => {
|
|
287
|
+
const i = present.indexOf(f);
|
|
288
|
+
if (i >= 0) return out[i] || null;
|
|
289
|
+
return f.includes('\n') ? null : 'deleted';
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A tick is Codehere's mark for a run it made on this machine (DESIGN.md §1). Text from the
|
|
294
|
+
// repo or from a teammate never brings its own.
|
|
295
|
+
export function noTick(text) {
|
|
296
|
+
return String(text).replace(/[✓✔☑✅]/g, '?');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A commit id read back from state.json, or null. The state file can arrive through git,
|
|
300
|
+
// so anything else (such as --output=<file>) must never reach git as an argument.
|
|
301
|
+
export function commitId(x) {
|
|
302
|
+
return typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x) ? x : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function isGitRepo(root) {
|
|
306
|
+
return git(root, 'rev-parse --is-inside-work-tree') === 'true';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function me(root) {
|
|
310
|
+
return git(root, 'config user.name') || process.env.USER || 'someone';
|
|
311
|
+
}
|