codehere 1.0.3 → 2.0.0-beta.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024
3
+ Copyright (c) 2026 Muhammad Ega
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
-
package/README.md CHANGED
@@ -1,65 +1,110 @@
1
- # codehere
1
+ # Codehere
2
+
3
+ **A team brain for vibe coders.** It stops your AI coding agent from going in circles, checks "done" for real, and shares what your team learns. It works through plain files in git.
4
+
5
+ Codehere is built to remove work, not add it. You keep using Claude Code as usual. Codehere talks to the agent, not to you, and stays silent unless it helps.
6
+
7
+ ## What it does
8
+
9
+ | Without Codehere, you… | With Codehere |
10
+ |---|---|
11
+ | Re-explain your team's rules every session | Team rules load into every session automatically |
12
+ | Type "still broken" and the agent guesses what you mean | Codehere adds the last error, the failing test and the files changed to your short message |
13
+ | Ask for a whole app with no way to check it | The agent asks you one question first: "How will you know it works?" Your answer becomes a test |
14
+ | Watch the agent try the same failing fix again and again | After the same error 3 times, the agent is told to stop and find the root cause first |
15
+ | Trust "Done!" and find it broken later | Codehere runs your tests itself. If they fail, the agent goes back to work. |
16
+ | Miss that a test was skipped or deleted to make it "pass" | The agent is blocked and asked to restore it |
17
+ | Lose a long, messy session and start over from scratch | When a long session keeps hitting the same error, the agent suggests `codehere brief`: one clean spec that the next session loads |
18
+ | Write a long message to ask a friend for help | `codehere help` writes a redacted help card for you |
19
+ | Solve a bug that a teammate already solved last week | Their fix reaches your agent the first time the error appears |
20
+
21
+ ## Install (2 minutes)
22
+
23
+ ```bash
24
+ npm install -g codehere@next # the 2.0 beta; codehere@latest is still 1.x
25
+ cd your-project
26
+ codehere init # finds your test command, adds hooks to .claude/settings.json
27
+ git add .codehere .claude/settings.json .gitignore
28
+ git commit -m "Add Codehere" && git push
29
+ ```
2
30
 
3
- > **Re-runs the tests an AI says passed, and tells you whether they did.**
4
- > `codehere check` on your machine, or the GitHub Action on every pull request. No account.
31
+ Each teammate installs the CLI once (`npm install -g codehere@next`). The hooks and the brain arrive through git.
5
32
 
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](https://github.com/muhammadegaa/codehere/blob/main/LICENSE)
33
+ ## The team loop
7
34
 
8
- `codehere check` re-runs your project's tests itself and reports the real result, so you do not have to take an agent's "all tests pass" on trust. It detects `npm test`, `node --test`, `pytest`, `go test`, `cargo test` and vitest or jest configs.
35
+ ```bash
36
+ codehere rule "Prices are in cents. Never use floats for money." # every agent follows it
9
37
 
10
- ## Install
38
+ # stuck?
39
+ codehere help "Cart total ignores quantity" # writes .codehere/help/<id>.md (redacted)
40
+ git add .codehere && git commit -m "help: cart" && git push
11
41
 
42
+ # teammate, after git pull (their agent also mentions it at session start)
43
+ codehere inbox
44
+ codehere reply <id> "Use price * qty; same bug was in checkout.js" --solved
45
+ git add .codehere && git commit -m "answer" && git push
12
46
  ```
13
- npx codehere check
14
- ```
15
-
16
- ## The four commands
17
-
18
- ```
19
- codehere check # re-run the tests here
20
- codehere check --base origin/main # also read everything this branch changed
21
- codehere check --claim "<the agent's summary>" # flag test files it named that do not exist
22
- codehere check --proof # a receipt you can paste into a pull request
23
- ```
24
-
25
- `codehere help check` lists the options. `--json` adds a `verdict` (`pass`, `fail`, `unverified` or `skipped`) and the receipt text.
26
47
 
27
- Exit codes:
48
+ `--solved` turns the answer into a **team lesson**. From then on, any teammate's agent that hits a similar error gets that fix automatically. When an agent solves a repeated error by itself, Codehere asks it to write a short lesson too.
28
49
 
29
- - **0**: the tests ran and passed, no test was weakened, no named test file is missing, and the weakened-test scan finished.
30
- - **1**: the tests failed; the runner reported no test that ran (0 tests, or every test skipped); the change made a test check nothing (an assertion swapped for `assert True` or `assert.ok(true)`, a skip marker, an assertion commented out, or a `return` above it); or a test file named in `--claim` is not in the repository. Usage errors (an unknown `--base`, a missing folder, an unknown option) also exit 1, with the message on stderr and nothing on stdout, and so does an internal error. Pass the claim as `--claim=<text>`: in `--claim --json` the claim is the text `--json`.
31
- - **2**: nothing could be verified: the run timed out, no test command was recognized, the runner is not installed, the re-run was turned off, or the weakened-test scan could not run (git is not installed, git could not read the repository, or the diff could not be read).
50
+ Don't want to commit help cards? Copy the printed text into WhatsApp, Discord or Slack instead.
32
51
 
33
- Settings, as environment variables: `CODEHERE_TEST_CMD` replaces the detected test command (split on whitespace, no shell); `CODEHERE_RERUN_TIMEOUT_MS` sets the time limit in whole milliseconds (default 120000, two minutes, also used for a value that is not a whole number from 1 to 2147483647); `CODEHERE_RERUN_TESTS=0` turns the re-run off. Detection covers `npm test` (a `test` script in `package.json`), `go test ./...`, `cargo test`, `pytest` (with `pytest.ini`, `conftest.py` or a pytest section in `pyproject.toml` or `setup.cfg`), `vitest.config.ts`/`.js`, `jest.config.js`/`.ts`, and `node --test` for `*.test.js` files.
52
+ ## Commands
34
53
 
35
- "No test ran" is read from the runner's own summary for node:test, pytest, jest, vitest and Go's `[no test files]`. A Go package with test files but no tests (`[no tests to run]`), `cargo test` with 0 tests, jest with `--passWithNoTests`, mocha, and a test script that only prints "No tests found" read as a pass. A test script that leaves a background process holding its output open is reported as timed out, even when the tests passed.
36
-
37
- ## GitHub Action
54
+ ```
55
+ codehere init [--test "npm test"] set up (safe to run again)
56
+ codehere rule ["text"] add or list team rules
57
+ codehere lesson ["problem" --fix "..."] add or list lessons
58
+ codehere brief [--gemma] restart brief for a fresh session
59
+ codehere help ["question"] [--gemma] help card for a teammate
60
+ codehere inbox [--all] teammates' help cards
61
+ codehere show <id> read a help card
62
+ codehere reply <id> "answer" [--solved] answer; --solved makes a lesson
63
+ codehere status what Codehere has seen
64
+ codehere stats turns to green, loops stopped, context size
65
+ ```
38
66
 
39
- A GitHub Action that posts the receipt on each pull request is in [`action/`](https://github.com/muhammadegaa/codehere/tree/main/action). It runs `npx codehere@<version> check --json --base origin/<base> --claim=<PR body>`. Its default version is 1.0.2. The 120-second default time limit applies, and the Action has no input to change it. With the default `fail-on-unverified: false`, a pull request that makes the suite unrunnable (removes the test script, breaks the runner) gets `unverified` and a green job with a warning, and a `skipped` verdict (the re-run turned off in the workflow) gets a green job with no warning. Set `fail-on-unverified: true` to fail the job on `unverified`. It is not published to the marketplace.
67
+ ## What lives where
40
68
 
41
- ## What it does not catch
69
+ ```
70
+ .codehere/
71
+ rules.md team rules (written by people)
72
+ lessons/ solved errors, one markdown file each
73
+ help/ help cards
74
+ briefs/ restart briefs
75
+ config.json test command, loop threshold
76
+ local/ this machine only, git-ignored
77
+ ```
42
78
 
43
- - **Weakened tests.** The detector reads Python (`test_*.py`, `*_test.py`), JavaScript and TypeScript (`*.test.*`, `*.spec.*`), Go (`*_test.go`) and PHP (`*Test.php`) test files, and files under `test`, `tests` or `__tests__`. It flags an assertion replaced by an always-true one (`assert True`, `assert.ok(true)`, `assertTrue(True)`, a value compared with itself), a skip marker (including `skipif(True)`, `it.only(`, `test.only(` or `describe.only(` outside strings and `//` comments (including `test.describe.only(`), and in JavaScript, TypeScript and Vue files `fit(` or `fdescribe(` at the start of a statement, after `=>`, or after `await`, `return` or `void`; variants such as `test.only.each` are not flagged, a JavaScript helper or method named `fit` at the start of a statement is flagged, and `.vue` files are read only under `test`, `tests` or `__tests__`), a commented-out assertion and an early `return`. It does not flag a deleted assertion, a deleted test or test file, a loosened expectation, an always-true check written another way, or an edited test script. A very small test file that is renamed and changed in the same commit can read to git as a new file, and a new file is not scanned. Without `--base` it sees only uncommitted changes, so a weakened test that is already committed passes; with `--base <ref>` it sees everything since the branch left that ref. Only test files are diffed; if their diff cannot be read (over 64 MB), the run is reported as unverified. In a folder with no git repository and no `--base`, or before the first commit, the scan is skipped and the output says so. When git is missing or fails on a repository that is there, or `--base` is given outside a repository, the run is reported as unverified.
44
- - **`--claim`** finds test files named in the summary in plain text, in quotes or backticks, in markdown bold or italics, in brackets, in `path::test` form and with a `:line` suffix. It ignores absolute paths, Windows backslash paths and Java `*Test.java` names. A file counts as present when any file with the same name exists in the checkout (outside `node_modules`, `dist`, `build`, `vendor`, `target` and virtual environments), so a file in the wrong folder is not reported. The search goes 8 folders deep and stops after 50,000 entries, so a named file deeper than that, or in a larger checkout, is reported as missing.
45
- - **A passing suite with a broken build, and code the tests do not cover.** A receipt says what the test suite showed, not that the software is correct.
46
- - **Who controls the tests.** `check` runs the project's own test command, so whoever controls that command controls the result, and whoever controls the environment can point the weakened-test scan at another tree (`GIT_DIR`, `GIT_WORK_TREE`). For a pull request from someone you do not trust, a receipt is not evidence. In the Action, use the `pull_request` trigger, not `pull_request_target`.
79
+ Everything is plain markdown, so you can read, edit, review and delete it like any other file.
47
80
 
48
- ## What has been measured
81
+ ## Design rules (from the research)
49
82
 
50
- - **False-done eval, 22 cases.** 14 false "tests pass" or "all green" claims (6 reconstructed from public posts, 8 planted) and 8 true ones. `codehere check` caught 12 of the 14 false claims, missed 2 (a green suite with a broken build, and green tests with a broken production job), and raised 0 false alarms on the 8 true ones. Result: [`agent/eval/false-done-result.json`](https://github.com/muhammadegaa/codehere/blob/main/agent/eval/false-done-result.json), produced by `node eval/false-done.mjs`. The cases were written to fit the detectors, so this is a small set and not a general rate.
51
- - **How often agent pull requests carry a failing claim: not measured.** A pre-registered study (S11) had two models label 299 public agent pull requests. They disagreed (Cohen's kappa 0.092), so the study reports no rate. The report, with the method and its limits: [`agent/eval/promise-verification/studies/S11-demand/REPORT.md`](https://github.com/muhammadegaa/codehere/blob/main/agent/eval/promise-verification/studies/S11-demand/REPORT.md).
83
+ - **No AI in the decision path.** Loops, failed tests and tampering are detected with plain rules, so Codehere doesn't inherit the agent's mistakes.
84
+ - **It talks to the agent, not to you.** If Codehere is wrong, the agent does one extra check. You lose nothing.
85
+ - **Silent on normal turns.** Your prompt gets extra context only after a failure, for a feature with no way to check it, or in a long session stuck on one error. Each at most once, under 300 tokens.
86
+ - **It only reacts to this session's work.** Changes you had before the session are ignored. If the agent changed nothing, Codehere does nothing.
87
+ - **It never traps the agent.** At most 3 "not done yet" messages per session, then it lets go.
88
+ - **It fails open.** If anything breaks, the hook exits quietly and the agent carries on.
89
+ - **Small model, small job.** Gemma 4 (via Ollama) is optional and only writes summaries for briefs and help cards, which you read before using. Turn it on with `--gemma`.
90
+ - **Only human-confirmed context at session start.** Rules are written by people. Lessons appear only when a matching error shows up, because auto-generated context files can make agents worse.
52
91
 
53
- ## Development
92
+ ## Try the demo (no Claude account needed)
54
93
 
94
+ ```bash
95
+ node demo/demo.mjs # two teammates, one repo, the whole loop
96
+ node demo/demo.mjs --step # press Enter between scenes (good for a live talk)
97
+ npm test # end-to-end checks
55
98
  ```
56
- cd agent
57
- npm ci
58
- npm test # build, then vitest
59
- npm run lint
60
- node eval/false-done.mjs
61
- ```
62
99
 
63
- ## License
100
+ ## Known limits (2.0.0, not released yet)
101
+
102
+ - Claude Code only for now. Gemini CLI and Codex hooks are next.
103
+ - Lessons match by error text. Very generic errors can bring up a lesson that doesn't fit, which is why the agent is told it "may apply".
104
+ - Redaction catches common keys, tokens and emails. Always read a help card before sharing it.
105
+ - The "done" check needs a test command. Without one, only the tampering check runs.
106
+ - Sharing is through git. There is no server, so teammates see updates after `git pull`.
107
+ - Prompt checks are plain word lists (English and Indonesian). They can miss a short prompt in another language.
108
+ - The weakened-test scan catches skipped tests, deleted test files and removed assertions. It does not yet catch an edited expected value; see `docs/overlap-with-check.md`.
64
109
 
65
- MIT. See [`LICENSE`](https://github.com/muhammadegaa/codehere/blob/main/LICENSE).
110
+ MIT licensed.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { runHook } from '../src/hooks.js';
3
+ import * as cmd from '../src/commands.js';
4
+
5
+ const argv = process.argv.slice(2);
6
+ const BOOLEAN = new Set(['gemma', 'solved', 'all', 'help', 'h']);
7
+ const flags = {};
8
+ const pos = [];
9
+ for (let i = 0; i < argv.length; i++) {
10
+ const a = argv[i];
11
+ if (a.startsWith('--')) {
12
+ const key = a.slice(2);
13
+ const next = argv[i + 1];
14
+ if (!BOOLEAN.has(key) && next !== undefined && !next.startsWith('--')) {
15
+ flags[key] = next;
16
+ i++;
17
+ } else flags[key] = true;
18
+ } else pos.push(a);
19
+ }
20
+
21
+ const [command, ...rest] = pos;
22
+
23
+ const USAGE = `codehere · a git-native team brain for vibe coders
24
+
25
+ Setup
26
+ codehere init [--test "npm test"] set up .codehere/ and Claude Code hooks
27
+
28
+ Team brain
29
+ codehere rule ["text"] add a team rule (or list rules)
30
+ codehere lesson ["problem" --fix "what fixed it" --sig "error text"]
31
+ add a lesson (or list lessons)
32
+
33
+ When you're stuck
34
+ codehere brief [--gemma] write a restart brief; the next session loads it
35
+ codehere help ["question"] [--gemma]
36
+ write a redacted help card for a teammate
37
+ codehere inbox [--all] see teammates' help cards
38
+ codehere show <id> read a help card
39
+ codehere reply <id> "answer" [--solved]
40
+ answer; --solved turns it into a team lesson
41
+
42
+ codehere status what Codehere has seen
43
+ codehere stats turns to green, loops stopped, session size
44
+
45
+ Hooks (called by Claude Code, not by you)
46
+ codehere hook session-start | prompt | post-tool | stop
47
+ `;
48
+
49
+ const run = async () => {
50
+ switch (command) {
51
+ case 'hook':
52
+ return runHook(rest[0]);
53
+ case 'init':
54
+ return cmd.init(flags);
55
+ case 'rule':
56
+ case 'rules':
57
+ return cmd.rule(rest.join(' '));
58
+ case 'lesson':
59
+ case 'lessons':
60
+ return cmd.lesson(rest.join(' '), flags);
61
+ case 'brief':
62
+ return cmd.brief(flags);
63
+ case 'help':
64
+ if (flags.h) return console.log(USAGE);
65
+ return cmd.help(rest.join(' '), flags);
66
+ case 'inbox':
67
+ return cmd.inbox(flags);
68
+ case 'show':
69
+ return cmd.show(rest[0]);
70
+ case 'reply':
71
+ return cmd.reply(rest[0], rest.slice(1).join(' '), flags);
72
+ case 'status':
73
+ return cmd.status();
74
+ case 'stats':
75
+ return cmd.stats();
76
+ default:
77
+ if (!command || command === '-h') return console.log(USAGE);
78
+ // stderr only: a hook's stdout can land in the agent's context (e.g. a codehere 1.x
79
+ // user-level hook such as `codehere scan-tool`). Exit 0 when a hook called us.
80
+ console.error(`Unknown command "${command}". Run codehere --help.`);
81
+ if (process.stdin.isTTY) process.exitCode = 1;
82
+ }
83
+ };
84
+
85
+ run().catch((err) => {
86
+ // hooks must never break the agent
87
+ if (command === 'hook') process.exit(0);
88
+ console.error(err.message);
89
+ process.exit(1);
90
+ });
package/package.json CHANGED
@@ -1,79 +1,35 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "1.0.3",
4
- "description": "Re-runs the tests an AI says passed, and tells you whether they did. Flags weakened tests and test files that do not exist.",
5
- "main": "dist/index.js",
3
+ "version": "2.0.0-beta.0",
4
+ "description": "A git-native team brain for vibe coders. Silent fixes for AI coding agents: breaks doom loops, verifies 'done', blocks test tampering, and shares what your team learned.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/muhammadegaa/codehere.git",
8
+ "directory": "v2"
9
+ },
10
+ "homepage": "https://github.com/muhammadegaa/codehere/tree/v2-team-brain/v2#readme",
6
11
  "type": "module",
7
12
  "bin": {
8
- "codehere": "dist/index.js",
9
- "ch": "dist/index.js"
13
+ "codehere": "bin/codehere.js"
10
14
  },
11
15
  "files": [
12
- "dist",
13
- "README.md",
14
- "LICENSE"
16
+ "bin",
17
+ "src",
18
+ "README.md"
15
19
  ],
16
20
  "engines": {
17
- "node": ">=20"
21
+ "node": ">=18"
18
22
  },
19
- "os": [
20
- "darwin",
21
- "linux"
22
- ],
23
23
  "scripts": {
24
- "prepare": "bash ../scripts/install-hooks.sh || true",
25
- "prebuild": "rm -rf dist",
26
- "build": "tsc",
27
- "postbuild": "chmod +x dist/index.js",
28
- "prepublishOnly": "npm run build && find dist -name '*.map' -delete && find dist -name '*.d.ts' -delete && find dist -name '*.d.ts.map' -delete",
29
- "start": "node dist/index.js",
30
- "dev": "tsx src/index.ts",
31
- "test": "npm run build && vitest run",
32
- "test:watch": "vitest",
33
- "lint": "eslint src/ --ext .ts",
34
- "knip": "knip",
35
- "knip:gate": "node scripts/knip-gate.mjs",
36
- "arch:gate": "tsc --noEmit && eslint src/ --ext .ts && node scripts/knip-gate.mjs",
37
- "format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'",
38
- "format:check": "prettier --check 'src/**/*.ts' 'test/**/*.ts'",
39
- "sbom": "npm sbom --sbom-format=spdx --omit=dev",
40
- "pretag:check": "../scripts/pretag-npm-check.sh"
24
+ "test": "node test/e2e.mjs"
41
25
  },
42
26
  "keywords": [
43
- "ai-coding-agents",
44
- "test-verification",
45
- "ci",
46
- "github-action",
47
27
  "claude-code",
48
- "codex",
49
- "cli-tool",
50
- "typescript"
28
+ "ai-agents",
29
+ "vibe-coding",
30
+ "hooks",
31
+ "team",
32
+ "memory"
51
33
  ],
52
- "author": "The codehere authors",
53
- "license": "MIT",
54
- "repository": {
55
- "type": "git",
56
- "url": "git+https://github.com/muhammadegaa/codehere.git"
57
- },
58
- "bugs": {
59
- "url": "https://github.com/muhammadegaa/codehere/issues"
60
- },
61
- "homepage": "https://github.com/muhammadegaa/codehere",
62
- "dependencies": {
63
- "chalk": "^5.3.0",
64
- "commander": "^11.1.0"
65
- },
66
- "devDependencies": {
67
- "@eslint/js": "^10.0.1",
68
- "@types/node": "^20.19.25",
69
- "eslint": "^10.2.0",
70
- "eslint-import-resolver-typescript": "^4.4.5",
71
- "eslint-plugin-import-x": "^4.16.2",
72
- "knip": "^6.16.1",
73
- "prettier": "^3.8.3",
74
- "tsx": "^4.21.0",
75
- "typescript": "^5.9.3",
76
- "typescript-eslint": "^8.58.2",
77
- "vitest": "^4.1.4"
78
- }
34
+ "license": "MIT"
79
35
  }
package/src/brain.js ADDED
@@ -0,0 +1,186 @@
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 path from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ export function findRoot(start = process.cwd()) {
8
+ let dir = path.resolve(start);
9
+ while (true) {
10
+ if (fs.existsSync(path.join(dir, '.codehere'))) return dir;
11
+ const parent = path.dirname(dir);
12
+ if (parent === dir) return null;
13
+ dir = parent;
14
+ }
15
+ }
16
+
17
+ export function paths(root) {
18
+ const base = path.join(root, '.codehere');
19
+ return {
20
+ base,
21
+ config: path.join(base, 'config.json'),
22
+ rules: path.join(base, 'rules.md'),
23
+ lessons: path.join(base, 'lessons'),
24
+ help: path.join(base, 'help'),
25
+ briefs: path.join(base, 'briefs'),
26
+ local: path.join(base, 'local'),
27
+ state: path.join(base, 'local', 'state.json'),
28
+ };
29
+ }
30
+
31
+ export function ensureDirs(root) {
32
+ const p = paths(root);
33
+ for (const d of [p.base, p.lessons, p.help, p.briefs, p.local]) {
34
+ fs.mkdirSync(d, { recursive: true });
35
+ }
36
+ return p;
37
+ }
38
+
39
+ const DEFAULT_CONFIG = {
40
+ testCommand: null,
41
+ verifyOnStop: true,
42
+ loopThreshold: 3,
43
+ testTimeoutSeconds: 120,
44
+ longSessionTokens: 120000,
45
+ gemmaModel: 'gemma4:e4b',
46
+ };
47
+
48
+ export function readConfig(root) {
49
+ const p = paths(root);
50
+ try {
51
+ return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(p.config, 'utf8')) };
52
+ } catch {
53
+ return { ...DEFAULT_CONFIG };
54
+ }
55
+ }
56
+
57
+ export function writeConfig(root, cfg) {
58
+ fs.writeFileSync(paths(root).config, JSON.stringify(cfg, null, 2) + '\n');
59
+ }
60
+
61
+ // ---------- local session state ----------
62
+
63
+ export function readState(root) {
64
+ try {
65
+ return JSON.parse(fs.readFileSync(paths(root).state, 'utf8'));
66
+ } catch {
67
+ return { sessions: {}, lastSession: null, lastGreen: null, pendingBrief: null };
68
+ }
69
+ }
70
+
71
+ export function writeState(root, state) {
72
+ const p = paths(root);
73
+ fs.mkdirSync(p.local, { recursive: true });
74
+ fs.writeFileSync(p.state, JSON.stringify(state, null, 2));
75
+ }
76
+
77
+ export function session(state, id) {
78
+ const key = id || 'default';
79
+ if (!state.sessions[key]) {
80
+ state.sessions[key] = {
81
+ errors: {},
82
+ rawErrors: {},
83
+ shownLessons: [],
84
+ stopBlocks: 0,
85
+ lessonAsked: false,
86
+ startedAt: new Date().toISOString(),
87
+ transcript: null,
88
+ };
89
+ }
90
+ state.lastSession = key;
91
+ state.sessions[key].lastActive = new Date().toISOString();
92
+ // keep the state file small: drop sessions older than the 20 most recent
93
+ const keys = Object.keys(state.sessions);
94
+ if (keys.length > 20) {
95
+ keys
96
+ .sort((a, b) => (state.sessions[a].startedAt || '').localeCompare(state.sessions[b].startedAt || ''))
97
+ .slice(0, keys.length - 20)
98
+ .forEach((k) => delete state.sessions[k]);
99
+ }
100
+ return state.sessions[key];
101
+ }
102
+
103
+ // ---------- markdown with simple frontmatter ----------
104
+
105
+ export function parseDoc(text) {
106
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
107
+ if (!m) return { meta: {}, body: text };
108
+ const meta = {};
109
+ for (const line of m[1].split('\n')) {
110
+ const i = line.indexOf(':');
111
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
112
+ }
113
+ return { meta, body: m[2] };
114
+ }
115
+
116
+ export function renderDoc(meta, body) {
117
+ const head = Object.entries(meta)
118
+ .map(([k, v]) => `${k}: ${String(v).replace(/\n/g, ' ')}`)
119
+ .join('\n');
120
+ return `---\n${head}\n---\n${body.trimEnd()}\n`;
121
+ }
122
+
123
+ export function listDocs(dir) {
124
+ if (!fs.existsSync(dir)) return [];
125
+ return fs
126
+ .readdirSync(dir)
127
+ .filter((f) => f.endsWith('.md'))
128
+ .sort()
129
+ .map((f) => {
130
+ const file = path.join(dir, f);
131
+ const { meta, body } = parseDoc(fs.readFileSync(file, 'utf8'));
132
+ return { id: f.replace(/\.md$/, ''), file, meta, body };
133
+ });
134
+ }
135
+
136
+ export function slug(text, max = 40) {
137
+ return (
138
+ String(text)
139
+ .toLowerCase()
140
+ .replace(/[^a-z0-9]+/g, '-')
141
+ .replace(/^-+|-+$/g, '')
142
+ .slice(0, max) || 'note'
143
+ );
144
+ }
145
+
146
+ export function stamp() {
147
+ const d = new Date();
148
+ const pad = (n) => String(n).padStart(2, '0');
149
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
150
+ }
151
+
152
+ export function today() {
153
+ return new Date().toISOString().slice(0, 10);
154
+ }
155
+
156
+ // ---------- rules ----------
157
+
158
+ export function readRules(root) {
159
+ const file = paths(root).rules;
160
+ if (!fs.existsSync(file)) return [];
161
+ return fs
162
+ .readFileSync(file, 'utf8')
163
+ .split('\n')
164
+ .filter((l) => l.startsWith('- '))
165
+ .map((l) => l.slice(2).trim());
166
+ }
167
+
168
+ // ---------- git helpers (all fail-safe) ----------
169
+
170
+ export function git(root, args) {
171
+ try {
172
+ return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] })
173
+ .toString()
174
+ .trim();
175
+ } catch {
176
+ return '';
177
+ }
178
+ }
179
+
180
+ export function isGitRepo(root) {
181
+ return git(root, 'rev-parse --is-inside-work-tree') === 'true';
182
+ }
183
+
184
+ export function me(root) {
185
+ return git(root, 'config user.name') || process.env.USER || 'someone';
186
+ }