stet-cli 0.0.0-stage → 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 — 2026-11-13
4
+ - Published on npm as stet-cli (the command is still `stet`).
5
+
6
+ ## 0.1.1 — 2026-11-13
7
+ - stet check now says when a proof command cannot run (missing script, command not found) instead of reporting a regression.
8
+
9
+ ## 0.1.0 — 2026-11-13
10
+ First release: init, sync, scope, guard, lock, check, context, decide, handoff, hook, status, mcp.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shamaz Khan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # stet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Stop AI coding agents from breaking what already works.**
4
+
5
+ Stet is a small, free command-line tool that gives every AI coding tool (Claude Code, Cursor, Codex, Copilot, Windsurf or a plain chat) the same rules, the same up-to-date picture of your repo, and a hard check before work is called done.
6
+
7
+ - **Charter → `AGENTS.md`.** Write your product context and do's and don'ts once in `.stet/charter.md`. Stet keeps `AGENTS.md` (read by Codex, Cursor, Copilot and others) and `CLAUDE.md` in sync.
8
+ - **Locked behaviours.** Lock what already works, each with a command that proves it. `stet check` runs them all. If one fails, the AI fixes its change, not the test.
9
+ - **Scope guard.** Agree the task and the files it may touch. `stet guard` flags every file changed outside scope or in protected paths, even if the agent already committed it.
10
+ - **Git-synced context.** `stet context` builds a context pack from the live repo (charter, scope, locks, decisions, commits, uncommitted changes, file tree and the files you choose). Paste it into any chat instead of uploading your `src` folder.
11
+ - **Decision log.** `stet decide` records "we chose X because Y", so the next session doesn't reopen it.
12
+ - **Handoff pack.** `stet handoff` writes an onboarding document for a new developer or CTO, built from the repo.
13
+ - **MCP server.** `stet mcp` exposes all of this as tools to Claude Code, Cursor and any MCP client.
14
+
15
+ No account. No telemetry. Your code never leaves your machine. Zero dependencies. Node 18+.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install -g stet-cli # or run any command with: npx stet-cli <command>
21
+ cd your-project
22
+ stet init
23
+ ```
24
+
25
+ `stet init` detects your stack and test command, then creates:
26
+
27
+ ```
28
+ .stet/
29
+ charter.md what this is, stack, always / never rules ← edit this
30
+ config.json name, protected paths, ignore globs
31
+ locks.json locked behaviours
32
+ scope.json current task scope
33
+ decisions.md decision log
34
+ AGENTS.md generated working agreement (your own text outside the stet block is kept)
35
+ CLAUDE.md imports AGENTS.md for Claude Code
36
+ ```
37
+
38
+ Commit `.stet/`, `AGENTS.md` and `CLAUDE.md`. Every agent and teammate then works from the same rules.
39
+
40
+ ## The loop
41
+
42
+ ```bash
43
+ # once: lock what already works
44
+ stet lock add "UAE VAT maths is correct" --cmd "node --test test/vat.test.js" --protect "src/vat/**"
45
+
46
+ # before each AI task
47
+ stet scope set "Add CSV export of invoices" --allow "src/export/**,test/export*"
48
+ stet context --files "src/invoices/**" # copied to clipboard; paste into your chat
49
+
50
+ # after the AI says it's done
51
+ stet guard # anything outside scope or in protected paths?
52
+ stet check # do all locked behaviours still pass?
53
+ ```
54
+
55
+ Example (real output from a sample accounting app):
56
+
57
+ ```
58
+ $ stet guard
59
+ Guarding 4 changed files (changes since 12b672b) · task: Add CSV export of invoices
60
+ ✗ migrations/002_export.sql out of scope protected
61
+ ✓ src/export/csv.js
62
+ ✗ src/vat/vat.js out of scope affects L2
63
+ ✓ test/export.test.js
64
+
65
+ ✗ 2 files outside scope or protected. Revert, or widen the scope on purpose.
66
+ ! Changes touch locked behaviour L2. Run: stet check --only L2
67
+
68
+ $ stet check
69
+ Checking 2 locked behaviours
70
+ ✗ L2 UAE VAT maths is correct (0.1s)
71
+ │ not ok 1 - 5% standard VAT, rounded to fils
72
+ │ expected: 10
73
+ │ actual: 9.99
74
+
75
+ ✗ 1 locked behaviour broken: L2. Fix the change, not the check.
76
+ ```
77
+
78
+ ## Commands
79
+
80
+ | Command | What it does |
81
+ |---|---|
82
+ | `stet init [--name X] [--mcp]` | Set up `.stet/`, `AGENTS.md` and `CLAUDE.md`. `--mcp` also writes `.mcp.json`. Safe to re-run. |
83
+ | `stet sync` | Regenerate the Stet block in `AGENTS.md` (other commands do this automatically). |
84
+ | `stet scope set "<task>" --allow "<globs>"` | Agree the task and the files it may touch. Records the current commit as the base. |
85
+ | `stet scope [show\|clear]` | Show or clear the scope. |
86
+ | `stet guard [--staged] [--base ref] [--warn]` | Check changes since the scope base (committed, staged, unstaged and untracked). Exits 1 on violations unless `--warn`. |
87
+ | `stet lock add "<behaviour>" --cmd "<cmd>" [--protect "<globs>"]` | Lock a behaviour. `--protect` tells guard which files affect it. |
88
+ | `stet lock [list\|rm L2]` | List locks with their last result, or remove one. |
89
+ | `stet check [--only L1,L2]` | Run locked behaviours. Exits 1 if any fail and shows the failing lines. |
90
+ | `stet context [--files "<globs>"] [--changed] [--diff] [--copy\|--stdout\|--out f]` | Build the context pack. By default it writes `.stet/context.md` and copies it to the clipboard. |
91
+ | `stet decide "<decision>" [--why "<reason>"]` | Add to the decision log. |
92
+ | `stet decisions [--all]` | List decisions. |
93
+ | `stet handoff [--out HANDOFF.md] [--stdout]` | Generate the onboarding pack. |
94
+ | `stet hook install [--check]` | Add a git pre-commit hook that blocks commits staging out-of-scope or protected files. `--check` also runs `stet check`. |
95
+ | `stet status` | One-screen summary. |
96
+ | `stet mcp` / `stet mcp install` | Run the MCP server over stdio, or write `.mcp.json`. |
97
+
98
+ Add `--json` to any command for machine-readable output.
99
+
100
+ ## Use with your AI tools
101
+
102
+ **Claude Code:** run `stet init --mcp`, or `claude mcp add stet -- npx -y stet-cli mcp`. Claude Code reads `CLAUDE.md`, which imports `AGENTS.md`.
103
+
104
+ **Cursor:** add this to `.cursor/mcp.json`:
105
+
106
+ ```json
107
+ { "mcpServers": { "stet": { "command": "npx", "args": ["-y", "stet-cli", "mcp"] } } }
108
+ ```
109
+
110
+ **Codex, Copilot, Windsurf, Gemini CLI:** they read `AGENTS.md` directly.
111
+
112
+ **ChatGPT, Claude.ai, Gemini (chat):** run `stet context --files "<what you're working on>"` and paste the result.
113
+
114
+ MCP tools: `stet_context`, `stet_guard`, `stet_check`, `stet_scope` (read-only; only the human sets scope) and `stet_decide`.
115
+
116
+ ## Configuration
117
+
118
+ `.stet/config.json`:
119
+
120
+ ```json
121
+ {
122
+ "name": "Ledgerly",
123
+ "protect": ["migrations/**", ".github/**"],
124
+ "ignore": ["fixtures/**"],
125
+ "maxFileKB": 64,
126
+ "checkTimeoutSec": 600
127
+ }
128
+ ```
129
+
130
+ Globs support `*`, `**` and `?`. A bare folder name like `migrations` matches everything inside it.
131
+
132
+ ## In CI
133
+
134
+ ```yaml
135
+ - run: npx -y stet-cli guard --base origin/main --warn
136
+ - run: npx -y stet-cli check
137
+ ```
138
+
139
+ ## FAQ
140
+
141
+ **Does Stet send my code anywhere?** No. It's a local CLI with no network calls and no telemetry.
142
+
143
+ **Is this just AGENTS.md?** `AGENTS.md` tells the agent the rules. Stet keeps those rules current, and `guard` and `check` verify the agent actually followed them.
144
+
145
+ **What if a locked behaviour has to change on purpose?** Change it deliberately: update the test, then `stet lock rm` / `stet lock add`, and record why with `stet decide`.
146
+
147
+ ## Licence
148
+
149
+ MIT
package/bin/stet.js ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const core = require('../lib/core');
6
+ const { c } = core;
7
+
8
+ const BOOL = new Set(['help', 'h', 'version', 'v', 'staged', 'warn', 'diff', 'changed', 'stdout', 'copy', 'all', 'mcp', 'uninstall', 'check', 'json', 'no-color']);
9
+ function parse(argv) {
10
+ const pos = [], fl = {};
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a.startsWith('--') || (a.startsWith('-') && a.length === 2)) {
14
+ let k = a.replace(/^--?/, ''), v;
15
+ if (k.includes('=')) [k, v] = [k.slice(0, k.indexOf('=')), k.slice(k.indexOf('=') + 1)];
16
+ else if (BOOL.has(k)) v = true;
17
+ else v = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
18
+ fl[k] = v;
19
+ } else pos.push(a);
20
+ }
21
+ return { pos, fl };
22
+ }
23
+
24
+ const HELP = `${c.bold('stet')} ${c.dim(core.VERSION)} — keep AI coding agents from breaking what already works
25
+
26
+ ${c.bold('Setup')}
27
+ stet init [--name X] [--mcp] Create .stet/, AGENTS.md and CLAUDE.md (detects your stack)
28
+ stet sync Regenerate AGENTS.md from .stet/
29
+
30
+ ${c.bold('Before an AI task')}
31
+ stet scope set "<task>" --allow "src/x/**,tests/x/**"
32
+ stet scope [show|clear]
33
+ stet context [--files "glob,glob"] [--changed] [--diff] [--copy|--stdout|--out file]
34
+ Git-synced context pack for any AI chat (no more uploading src/)
35
+
36
+ ${c.bold('After an AI task')}
37
+ stet guard [--staged] [--base ref] [--warn] Flag changes outside scope or in protected paths
38
+ stet check [--only L1,L2] Run locked behaviours; non-zero exit if any broke
39
+
40
+ ${c.bold('Memory')}
41
+ stet lock add "<behaviour>" --cmd "<proof command>" [--protect "glob,glob"]
42
+ stet lock [list|rm L2]
43
+ stet decide "<decision>" [--why "<reason>"] Append to the decision log
44
+ stet decisions [--all]
45
+
46
+ ${c.bold('Team')}
47
+ stet handoff [--out HANDOFF.md] Onboarding pack for a new dev or CTO, built from the repo
48
+ stet hook install [--check] | stet hook uninstall
49
+ stet status
50
+ stet mcp Run as an MCP server (Claude Code, Cursor, any MCP client)
51
+ stet mcp install Write .mcp.json for this project
52
+
53
+ Docs: https://stet-8fe08a58.base44.app`;
54
+
55
+ function main() {
56
+ const { pos, fl } = parse(process.argv.slice(2));
57
+ if (fl['no-color']) core.setColor(false);
58
+ const [cmd, sub, ...rest] = pos;
59
+ if (fl.version || fl.v) return console.log(core.VERSION);
60
+ if (!cmd || cmd === 'help' || fl.help || fl.h) return console.log(HELP);
61
+ if (cmd === 'mcp' && !sub) return require('../lib/mcp').serve();
62
+
63
+ const root = core.findRoot(process.cwd());
64
+ const r = new core.Repo(root);
65
+ let res;
66
+ switch (cmd) {
67
+ case 'init': res = core.init(r, { name: fl.name, mcp: fl.mcp }); break;
68
+ case 'sync': res = core.sync(r); break;
69
+ case 'decide': res = core.decide(r, [sub, ...rest].filter(Boolean).join(' '), { why: fl.why }); break;
70
+ case 'decisions': res = core.decisions(r, { all: fl.all }); break;
71
+ case 'lock': case 'locks':
72
+ if (!sub || sub === 'list' || sub === 'ls') res = core.lockList(r);
73
+ else if (sub === 'add') res = core.lockAdd(r, rest.join(' '), { cmd: fl.cmd, protect: fl.protect });
74
+ else if (sub === 'rm' || sub === 'remove') res = core.lockRm(r, rest[0]);
75
+ else throw new core.StetError('Usage: stet lock [list|add|rm]');
76
+ break;
77
+ case 'check': res = core.check(r, { only: fl.only }); break;
78
+ case 'scope':
79
+ if (!sub || sub === 'show') res = core.scopeShow(r);
80
+ else if (sub === 'set') res = core.scopeSet(r, rest.join(' '), { allow: fl.allow });
81
+ else if (sub === 'clear') res = core.scopeClear(r);
82
+ else throw new core.StetError('Usage: stet scope [show|set|clear]');
83
+ break;
84
+ case 'guard': res = core.guard(r, { staged: fl.staged, base: fl.base, warn: fl.warn }); break;
85
+ case 'context': case 'ctx': {
86
+ core.setColor(false);
87
+ const ctx = core.context(r, { files: fl.files, changed: fl.changed, diff: fl.diff, maxKB: fl['max-kb'] });
88
+ core.setColor((process.stdout.isTTY || process.env.STET_COLOR === '1') && !process.env.NO_COLOR && !fl['no-color']);
89
+ if (fl.stdout) { process.stdout.write(ctx.text); return; }
90
+ const file = fl.out ? path.resolve(fl.out) : r.p('context.md');
91
+ fs.writeFileSync(file, ctx.text);
92
+ const copied = fl.copy || !fl.out ? core.copyToClipboard(ctx.text) : false;
93
+ const kb = (Buffer.byteLength(ctx.text) / 1024).toFixed(1);
94
+ res = { code: 0, text: `${c.green('✓')} Context pack: ${c.bold(path.relative(process.cwd(), file) || file)} ${c.dim(`(${kb} KB, ~${Math.round(Buffer.byteLength(ctx.text) / 4).toLocaleString()} tokens, ${ctx.data.files} files in tree, ${ctx.data.included} with contents)`)}\n ${copied ? 'Copied to clipboard. Paste it at the top of your AI chat.' : 'Attach or paste this file at the top of your AI chat.'}` };
95
+ break;
96
+ }
97
+ case 'handoff': {
98
+ const h = core.handoff(r);
99
+ if (fl.stdout) { process.stdout.write(h.text); return; }
100
+ const file = path.resolve(fl.out || path.join(root, 'HANDOFF.md'));
101
+ fs.writeFileSync(file, h.text);
102
+ res = { code: 0, text: `${c.green('✓')} Handoff pack written to ${c.bold(path.relative(process.cwd(), file) || file)}` };
103
+ break;
104
+ }
105
+ case 'status': case 'st': res = core.status(r); break;
106
+ case 'hook':
107
+ if (sub === 'install') res = core.hook(r, { withCheck: fl.check });
108
+ else if (sub === 'uninstall') res = core.hook(r, { uninstall: true });
109
+ else throw new core.StetError('Usage: stet hook install [--check] | stet hook uninstall');
110
+ break;
111
+ case 'mcp':
112
+ if (sub === 'install') res = core.mcpInstall(r); else throw new core.StetError('Usage: stet mcp | stet mcp install');
113
+ break;
114
+ default: throw new core.StetError(`Unknown command "${cmd}". Run stet help.`);
115
+ }
116
+ if (fl.json) console.log(JSON.stringify({ code: res.code, data: res.data, text: res.text.replace(/\x1b\[[0-9;]*m/g, '') }, null, 2));
117
+ else console.log(res.text);
118
+ process.exitCode = res.code;
119
+ }
120
+
121
+ try { main(); } catch (e) {
122
+ if (e instanceof core.StetError) { console.error(c.red('stet: ') + e.message); process.exitCode = e.code || 1; }
123
+ else { console.error(e); process.exitCode = 2; }
124
+ }
package/lib/core.js ADDED
@@ -0,0 +1,580 @@
1
+ 'use strict';
2
+ // Stet core: every command returns { text, code } so the CLI and the MCP server share one implementation.
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const cp = require('child_process');
6
+ const VERSION = require('../package.json').version;
7
+
8
+ // ---------- output helpers ----------
9
+ let COLOR = (!!process.stdout.isTTY || process.env.STET_COLOR === '1') && !process.env.NO_COLOR;
10
+ const setColor = (v) => { COLOR = !!v; };
11
+ const paint = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s));
12
+ const c = { bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), yellow: paint(33), cyan: paint(36), violet: paint(35) };
13
+ const OK = () => c.green('✓'), BAD = () => c.red('✗'), WARN = () => c.yellow('!');
14
+ const today = () => new Date().toISOString().slice(0, 10);
15
+ const out = (text, code = 0, data) => ({ text, code, data });
16
+
17
+ class StetError extends Error { constructor(m, code = 1) { super(m); this.code = code; } }
18
+
19
+ // ---------- git + fs ----------
20
+ function sh(cmd, args, cwd) { return cp.spawnSync(cmd, args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); }
21
+ function git(args, cwd) {
22
+ const r = sh('git', args, cwd);
23
+ if (r.error || r.status !== 0) throw new StetError(((r.stderr || '') + '').trim() || `git ${args.join(' ')} failed`);
24
+ return r.stdout;
25
+ }
26
+ function tgit(args, cwd, d = '') { try { return git(args, cwd); } catch { return d; } }
27
+ const nonEmpty = (s) => String(s || '').split('\n').filter((x) => x.trim() !== '');
28
+ const names = (s) => nonEmpty(s).map((x) => x.trim());
29
+
30
+ function findRoot(start = process.cwd()) {
31
+ let d = path.resolve(start);
32
+ for (;;) {
33
+ if (fs.existsSync(path.join(d, '.stet'))) return d;
34
+ const p = path.dirname(d);
35
+ if (p === d) break;
36
+ d = p;
37
+ }
38
+ return tgit(['rev-parse', '--show-toplevel'], start).trim() || path.resolve(start);
39
+ }
40
+
41
+ class Repo {
42
+ constructor(root) { this.root = root; this.dir = path.join(root, '.stet'); }
43
+ p(...a) { return path.join(this.dir, ...a); }
44
+ has() { return fs.existsSync(this.dir); }
45
+ need() { if (!this.has()) throw new StetError('No .stet/ folder found. Run `stet init` in your project first.'); }
46
+ isGit() { return !!tgit(['rev-parse', '--git-dir'], this.root).trim(); }
47
+ read(f, d = '') { try { return fs.readFileSync(this.p(f), 'utf8'); } catch { return d; } }
48
+ write(f, s) { fs.mkdirSync(path.dirname(this.p(f)), { recursive: true }); fs.writeFileSync(this.p(f), s); }
49
+ json(f, d) { try { return JSON.parse(this.read(f)); } catch { return d; } }
50
+ wjson(f, o) { this.write(f, JSON.stringify(o, null, 2) + '\n'); }
51
+ get config() { return { name: path.basename(this.root), protect: [], ignore: [], maxFileKB: 64, checkTimeoutSec: 600, ...this.json('config.json', {}) }; }
52
+ get locks() { return this.json('locks.json', []); }
53
+ get scope() { return this.json('scope.json', {}); }
54
+ tgit(a, d = '') { return tgit(a, this.root, d); }
55
+ head(full) { return this.tgit(['rev-parse', ...(full ? [] : ['--short']), 'HEAD']).trim(); }
56
+ branch() { return this.head() ? this.tgit(['rev-parse', '--abbrev-ref', 'HEAD']).trim() : '(no commits yet)'; }
57
+ rootFile(f) { try { return fs.readFileSync(path.join(this.root, f), 'utf8'); } catch { return null; } }
58
+ }
59
+
60
+ // ---------- globs ----------
61
+ function globRe(g) {
62
+ g = String(g).trim().replace(/^\.\//, '');
63
+ if (g.endsWith('/')) g += '**';
64
+ let re = '';
65
+ for (let i = 0; i < g.length; i++) {
66
+ const ch = g[i];
67
+ if (ch === '*') {
68
+ if (g[i + 1] === '*') { i++; if (g[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*'; } else re += '[^/]*';
69
+ } else if (ch === '?') re += '[^/]';
70
+ else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
71
+ }
72
+ return new RegExp('^' + re + '$');
73
+ }
74
+ function matches(file, globs) {
75
+ return (globs || []).some((g) => globRe(g).test(file) || (!/[*?]/.test(g) && file.startsWith(String(g).replace(/^\.\//, '').replace(/\/$/, '') + '/')));
76
+ }
77
+ const splitList = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map((s) => s.trim()).filter(Boolean);
78
+
79
+ // ---------- stack detection ----------
80
+ function detect(root) {
81
+ const ex = (f) => fs.existsSync(path.join(root, f));
82
+ const rd = (f) => { try { return fs.readFileSync(path.join(root, f), 'utf8'); } catch { return ''; } };
83
+ const s = { langs: [], frameworks: [], test: null, run: [], deps: [], entry: [] };
84
+ if (ex('package.json')) {
85
+ let pj = {}; try { pj = JSON.parse(rd('package.json')); } catch {}
86
+ const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}) };
87
+ s.deps.push(...Object.keys(pj.dependencies || {}));
88
+ s.langs.push(deps.typescript || ex('tsconfig.json') ? 'TypeScript' : 'JavaScript');
89
+ for (const [k, n] of [['next', 'Next.js'], ['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['express', 'Express'], ['@nestjs/core', 'NestJS'], ['fastify', 'Fastify'], ['hono', 'Hono'], ['vite', 'Vite'], ['prisma', 'Prisma'], ['drizzle-orm', 'Drizzle'], ['@supabase/supabase-js', 'Supabase'], ['stripe', 'Stripe'], ['tailwindcss', 'Tailwind']]) if (deps[k]) s.frameworks.push(n);
90
+ const pm = ex('pnpm-lock.yaml') ? 'pnpm' : ex('yarn.lock') ? 'yarn' : ex('bun.lockb') || ex('bun.lock') ? 'bun' : 'npm';
91
+ const sc = pj.scripts || {};
92
+ if (sc.test && !/no test specified/.test(sc.test)) s.test = `${pm} test`;
93
+ for (const k of ['dev', 'start', 'build', 'lint', 'typecheck']) if (sc[k]) s.run.push(`${pm} run ${k}`);
94
+ if (pj.main) s.entry.push(pj.main);
95
+ if (pj.bin) s.entry.push(...(typeof pj.bin === 'string' ? [pj.bin] : Object.values(pj.bin)));
96
+ }
97
+ if (ex('pyproject.toml') || ex('requirements.txt') || ex('setup.py')) {
98
+ s.langs.push('Python');
99
+ const txt = (rd('pyproject.toml') + '\n' + rd('requirements.txt')).toLowerCase();
100
+ for (const [k, n] of [['django', 'Django'], ['fastapi', 'FastAPI'], ['flask', 'Flask'], ['sqlalchemy', 'SQLAlchemy'], ['pydantic', 'Pydantic'], ['pandas', 'pandas'], ['celery', 'Celery']]) if (txt.includes(k)) s.frameworks.push(n);
101
+ s.deps.push(...nonEmpty(rd('requirements.txt')).filter((l) => !l.startsWith('#')).map((l) => l.split(/[=<>~! ;\[]/)[0]));
102
+ if (!s.test && (ex('tests') || ex('pytest.ini') || txt.includes('pytest'))) s.test = 'pytest -q';
103
+ if (ex('manage.py')) { s.run.push('python manage.py runserver'); s.entry.push('manage.py'); }
104
+ }
105
+ if (ex('go.mod')) { s.langs.push('Go'); s.test = s.test || 'go test ./...'; }
106
+ if (ex('Cargo.toml')) { s.langs.push('Rust'); s.test = s.test || 'cargo test'; }
107
+ if (ex('Gemfile')) s.langs.push('Ruby');
108
+ if (ex('Dockerfile') || ex('docker-compose.yml') || ex('compose.yaml')) s.frameworks.push('Docker');
109
+ for (const f of ['main.py', 'app.py', 'server.js', 'index.js', 'src/server.js', 'src/server.ts', 'src/app.js', 'src/index.ts', 'src/main.ts', 'src/index.js', 'src/app.ts', 'app/page.tsx', 'src/main.tsx', 'main.go', 'src/main.rs']) if (ex(f) && !s.entry.includes(f)) s.entry.push(f);
110
+ if (ex('Makefile')) s.run.push(...nonEmpty(rd('Makefile')).filter((l) => /^[a-zA-Z][\w-]*:/.test(l)).slice(0, 6).map((l) => 'make ' + l.split(':')[0]));
111
+ return s;
112
+ }
113
+
114
+ const NOISE = [/(^|\/)node_modules\//, /(^|\/)\.git\//, /(^|\/)(dist|build|\.next|out|coverage|__pycache__|\.venv|venv)\//, /\.(png|jpe?g|gif|webp|ico|pdf|zip|woff2?|ttf|mp4|mov|lock)$/i, /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/, /^\.stet\/(\.cache|context\.md)/];
115
+ function listFiles(r) {
116
+ let files;
117
+ if (r.isGit()) files = names(r.tgit(['ls-files', '--cached', '--others', '--exclude-standard']));
118
+ else {
119
+ files = [];
120
+ const walk = (d, rel) => {
121
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
122
+ if (['node_modules', '.git', 'dist', 'build', '.venv', 'venv'].includes(e.name)) continue;
123
+ const rp = rel ? rel + '/' + e.name : e.name;
124
+ if (e.isDirectory()) walk(path.join(d, e.name), rp); else files.push(rp);
125
+ if (files.length > 20000) return;
126
+ }
127
+ };
128
+ walk(r.root, '');
129
+ }
130
+ const ign = r.config.ignore;
131
+ return files.filter((f) => !NOISE.some((re) => re.test(f)) && !matches(f, ign)).sort();
132
+ }
133
+
134
+ function changedFiles(r, { base, staged } = {}) {
135
+ if (!r.isGit()) return [];
136
+ if (staged) return names(r.tgit(['diff', '--cached', '--name-only']));
137
+ const set = new Set();
138
+ if (r.head()) {
139
+ const b = base && r.tgit(['rev-parse', '--verify', '--quiet', base + '^{commit}']).trim() ? base : 'HEAD';
140
+ names(r.tgit(['diff', '--name-only', b])).forEach((f) => set.add(f));
141
+ } else names(r.tgit(['diff', '--cached', '--name-only'])).forEach((f) => set.add(f));
142
+ names(r.tgit(['ls-files', '--others', '--exclude-standard'])).forEach((f) => set.add(f));
143
+ return [...set].sort();
144
+ }
145
+ const STET_OWNED = ['.stet/**', 'AGENTS.md', 'CLAUDE.md', 'HANDOFF.md', '.mcp.json'];
146
+
147
+ // ---------- templates ----------
148
+ function charterTemplate(name, st) {
149
+ const stack = [...st.langs, ...st.frameworks].join(', ') || 'Describe your stack here';
150
+ return `# ${name}
151
+
152
+ ## What this is
153
+ <!-- One paragraph: what the product does, for whom, and what stage it is at. -->
154
+ ${name} is ...
155
+
156
+ ## Stack
157
+ - ${stack}
158
+ ${st.test ? `- Tests: \`${st.test}\`\n` : ''}${st.run.length ? `- Run: ${st.run.map((x) => '`' + x + '`').join(', ')}\n` : ''}
159
+ ## Always
160
+ - Read this charter, the current scope and the locked behaviours before changing code.
161
+ - Make the smallest change that solves the task. Match the existing style and patterns.
162
+ - Keep changes inside the current scope. If the task needs more, stop and ask.
163
+ - Run \`stet check\` before saying a task is done, and report the result honestly.
164
+ - Record any architectural decision with \`stet decide\`.
165
+
166
+ ## Never
167
+ - Never rewrite, rename or "clean up" working code outside the task.
168
+ - Never delete or weaken a test, check or validation to make something pass.
169
+ - Never add a dependency, service or env var without saying why.
170
+ - Never invent APIs, files, tables or config. If you are unsure, ask.
171
+ - Never commit secrets, keys or customer data.
172
+
173
+ ## Notes for agents
174
+ <!-- Anything an AI keeps getting wrong in this repo: naming, folders, gotchas. -->
175
+ `;
176
+ }
177
+
178
+ // ---------- AGENTS.md / CLAUDE.md sync ----------
179
+ const BEGIN = '<!-- stet:begin -->', END = '<!-- stet:end -->';
180
+ function decisionsList(r) {
181
+ const md = r.read('decisions.md');
182
+ const out = [];
183
+ const re = /^## (D-\d+) · (\S+) · (.+)$/gm; let m;
184
+ while ((m = re.exec(md))) out.push({ id: m[1], date: m[2], title: m[3].trim() });
185
+ return out;
186
+ }
187
+ function scopeBlock(r) {
188
+ const s = r.scope;
189
+ if (!s.task) return 'No scope is set. Before making broad changes, ask the human what the task is, or ask them to run `stet scope set`.';
190
+ return `**Task:** ${s.task}\n\nYou may change only:\n${s.allow.map((g) => `- \`${g}\``).join('\n')}\n\nIf the task needs a change outside this list, stop and ask. Do not tidy up, refactor or reformat other files.`;
191
+ }
192
+ function locksBlock(r) {
193
+ const L = r.locks;
194
+ if (!L.length) return 'No locked behaviours yet. The human adds them with `stet lock add`.';
195
+ return `These already work and must keep working:\n\n| ID | Behaviour | Proof (must pass) | Files that affect it |\n|---|---|---|---|\n${L.map((l) => `| ${l.id} | ${l.name} | \`${l.cmd}\` | ${(l.protect || []).map((g) => '`' + g + '`').join(', ') || '—'} |`).join('\n')}\n\nBefore saying any task is done, run \`stet check\`. If a check fails, fix your change. Never edit the test or weaken the check to make it pass.`;
196
+ }
197
+ function agentsBlock(r) {
198
+ const charter = r.read('charter.md').replace(/^# .*\n+/, '').replace(/<!--[\s\S]*?-->\n?/g, '').trim();
199
+ const prot = r.config.protect;
200
+ const ds = decisionsList(r).slice(-15);
201
+ return `${BEGIN}
202
+ <!-- Generated by Stet from .stet/. Edit .stet/charter.md or use stet commands. Text outside this block is yours and is kept. -->
203
+ # Working agreement for AI coding agents — ${r.config.name}
204
+
205
+ This is the source of truth for any AI agent working in this repo (Claude Code, Cursor, Codex, Copilot, Windsurf or a chat assistant). Read all of it before changing code. If anything said earlier in a chat conflicts with this file, this file wins.
206
+
207
+ ## Project charter
208
+ ${charter.replace(/^## /gm, '### ')}
209
+
210
+ ## Current task scope
211
+ ${scopeBlock(r)}
212
+
213
+ ## Locked behaviours
214
+ ${locksBlock(r)}
215
+
216
+ ## Protected paths
217
+ ${prot.length ? `Do not modify these without explicit approval from the human:\n${prot.map((g) => `- \`${g}\``).join('\n')}` : 'None set.'}
218
+
219
+ ## Decisions already made
220
+ ${ds.length ? `Do not reopen these unless the human asks:\n${ds.map((d) => `- **${d.id}** (${d.date}) ${d.title}`).join('\n')}` : 'None yet.'}
221
+
222
+ ## Workflow
223
+ 1. Load the current repo state with \`stet context\` (or the MCP tool \`stet_context\`). Trust it over older chat history.
224
+ 2. Work only inside the scope above.
225
+ 3. Run \`stet guard\` to confirm you only changed in-scope files.
226
+ 4. Run \`stet check\` to confirm every locked behaviour still passes.
227
+ 5. Record new architectural decisions with \`stet decide "<decision>" --why "<reason>"\`.
228
+ 6. Finish with a short summary: what changed, which files, check results and anything you were unsure about.
229
+ ${END}`;
230
+ }
231
+ function sync(r) {
232
+ r.need();
233
+ const block = agentsBlock(r);
234
+ const ap = path.join(r.root, 'AGENTS.md');
235
+ const cur = r.rootFile('AGENTS.md');
236
+ let next;
237
+ if (cur == null) next = block + '\n';
238
+ else if (cur.includes(BEGIN) && cur.includes(END)) next = cur.slice(0, cur.indexOf(BEGIN)) + block + cur.slice(cur.indexOf(END) + END.length);
239
+ else next = block + '\n\n' + cur;
240
+ const changed = next !== cur;
241
+ if (changed) fs.writeFileSync(ap, next);
242
+ const cl = r.rootFile('CLAUDE.md');
243
+ if (cl == null) fs.writeFileSync(path.join(r.root, 'CLAUDE.md'), '# Claude Code\n\nThe working agreement for this repo lives in AGENTS.md (maintained by Stet):\n\n@AGENTS.md\n');
244
+ else if (!cl.includes('@AGENTS.md')) fs.writeFileSync(path.join(r.root, 'CLAUDE.md'), cl.replace(/\s*$/, '') + '\n\n@AGENTS.md\n');
245
+ return out(changed ? `${OK()} AGENTS.md updated` : `${OK()} AGENTS.md already in sync`, 0, { changed });
246
+ }
247
+
248
+ // ---------- init ----------
249
+ function init(r, { name, mcp } = {}) {
250
+ const st = detect(r.root);
251
+ const nm = name || r.config.name;
252
+ const made = [], kept = [];
253
+ const put = (f, s) => { if (fs.existsSync(r.p(f))) kept.push(f); else { r.write(f, s); made.push(f); } };
254
+ put('config.json', JSON.stringify({ name: nm, version: 1, protect: [], ignore: [], maxFileKB: 64, checkTimeoutSec: 600 }, null, 2) + '\n');
255
+ put('charter.md', charterTemplate(nm, st));
256
+ put('locks.json', JSON.stringify(st.test ? [{ id: 'L1', name: 'Full test suite passes', cmd: st.test, protect: [], added: today() }] : [], null, 2) + '\n');
257
+ put('scope.json', '{}\n');
258
+ put('decisions.md', `# Decision log — ${nm}\n\nOne entry per decision. Newest at the bottom. Managed with \`stet decide\`.\n\n## D-001 · ${today()} · Adopted Stet to keep AI agents in scope\nAI agents read AGENTS.md. Locked behaviours are checked with \`stet check\` before work is called done.\n`);
259
+ put('.gitignore', '.cache/\ncontext.md\n');
260
+ sync(r);
261
+ const lines = [`${OK()} Stet initialised in ${c.bold(r.root)}`];
262
+ if (!r.isGit()) lines.push(`${WARN()} This folder is not a git repo. guard, context and handoff work best with git.`);
263
+ lines.push(` stack: ${[...st.langs, ...st.frameworks].join(', ') || 'not detected'}`);
264
+ lines.push(` created: ${made.map((f) => '.stet/' + f).join(', ') || 'nothing new'}${kept.length ? c.dim(` (kept ${kept.length} existing)`) : ''}`);
265
+ lines.push(` wrote: AGENTS.md, CLAUDE.md`);
266
+ if (mcp) lines.push(mcpInstall(r).text);
267
+ lines.push('', c.bold('Next:'),
268
+ ` 1. Edit ${c.cyan('.stet/charter.md')} — one paragraph on what this is, plus your rules.`,
269
+ ` 2. Lock what already works: ${c.cyan('stet lock add "Login works" --cmd "npm test -- auth" --protect "src/auth/**"')}`,
270
+ ` 3. Before each AI task: ${c.cyan('stet scope set "Add CSV export" --allow "src/export/**,tests/export/**"')}`,
271
+ ` 4. After the AI is done: ${c.cyan('stet guard && stet check')}`,
272
+ ` 5. Commit .stet/, AGENTS.md and CLAUDE.md so every agent and teammate gets the same rules.`);
273
+ return out(lines.join('\n'));
274
+ }
275
+
276
+ // ---------- decisions ----------
277
+ function decide(r, title, { why } = {}) {
278
+ r.need();
279
+ if (!title) throw new StetError('Usage: stet decide "<decision>" [--why "<reason>"]');
280
+ const ds = decisionsList(r);
281
+ const n = ds.length ? Math.max(...ds.map((d) => +d.id.slice(2))) + 1 : 1;
282
+ const id = 'D-' + String(n).padStart(3, '0');
283
+ const md = r.read('decisions.md').replace(/\s*$/, '\n');
284
+ r.write('decisions.md', `${md}\n## ${id} · ${today()} · ${title.replace(/\n/g, ' ')}\n${why ? why.trim() + '\n' : ''}`);
285
+ sync(r);
286
+ return out(`${OK()} Logged ${c.bold(id)}: ${title}`, 0, { id });
287
+ }
288
+ function decisions(r, { all } = {}) {
289
+ r.need();
290
+ const ds = decisionsList(r);
291
+ if (!ds.length) return out('No decisions logged yet.');
292
+ return out((all ? ds : ds.slice(-10)).map((d) => `${c.violet(d.id)} ${c.dim(d.date)} ${d.title}`).join('\n'));
293
+ }
294
+
295
+ // ---------- locks ----------
296
+ function lockAdd(r, name, { cmd, protect } = {}) {
297
+ r.need();
298
+ if (!name || !cmd) throw new StetError('Usage: stet lock add "<behaviour>" --cmd "<command that proves it>" [--protect "glob,glob"]');
299
+ const L = r.locks;
300
+ const n = L.length ? Math.max(...L.map((l) => +String(l.id).slice(1) || 0)) + 1 : 1;
301
+ const l = { id: 'L' + n, name, cmd, protect: splitList(protect), added: today() };
302
+ L.push(l); r.wjson('locks.json', L); sync(r);
303
+ return out(`${OK()} Locked ${c.bold(l.id)}: ${name}\n proof: ${c.cyan(cmd)}${l.protect.length ? `\n watches: ${l.protect.join(', ')}` : ''}`, 0, l);
304
+ }
305
+ function lockList(r) {
306
+ r.need();
307
+ const L = r.locks;
308
+ if (!L.length) return out('No locked behaviours. Add one with: stet lock add "<behaviour>" --cmd "<command>"');
309
+ const last = r.json('.cache/last-check.json', { results: [] });
310
+ return out(L.map((l) => {
311
+ const res = last.results.find((x) => x.id === l.id);
312
+ const mark = !res ? c.dim('·') : res.ok ? OK() : BAD();
313
+ return `${mark} ${c.bold(l.id.padEnd(4))} ${l.name}\n ${c.dim('$')} ${c.cyan(l.cmd)}${l.protect && l.protect.length ? c.dim(' watches ' + l.protect.join(', ')) : ''}`;
314
+ }).join('\n'));
315
+ }
316
+ function lockRm(r, id) {
317
+ r.need();
318
+ const L = r.locks; const i = L.findIndex((l) => l.id.toLowerCase() === String(id).toLowerCase());
319
+ if (i < 0) throw new StetError(`No lock with id ${id}. See: stet lock list`);
320
+ const [l] = L.splice(i, 1); r.wjson('locks.json', L); sync(r);
321
+ return out(`${OK()} Removed ${l.id}: ${l.name}`);
322
+ }
323
+ function check(r, { only } = {}) {
324
+ r.need();
325
+ let L = r.locks;
326
+ if (only) { const ids = splitList(only).map((s) => s.toLowerCase()); L = L.filter((l) => ids.includes(l.id.toLowerCase())); }
327
+ if (!L.length) return out(`${WARN()} Nothing to check. Lock a behaviour first: stet lock add "<behaviour>" --cmd "<command>"`);
328
+ const lines = [c.bold(`Checking ${L.length} locked behaviour${L.length > 1 ? 's' : ''}`)];
329
+ const results = [];
330
+ for (const l of L) {
331
+ const t0 = Date.now();
332
+ const p = cp.spawnSync(l.cmd, { cwd: r.root, shell: true, encoding: 'utf8', timeout: r.config.checkTimeoutSec * 1000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CI: process.env.CI || '1', FORCE_COLOR: '0' } });
333
+ const ms = Date.now() - t0;
334
+ const ok = p.status === 0;
335
+ const all = ((p.stdout || '') + (p.stderr || '')).replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n');
336
+ const KEY = /(not ok|fail(ed|ure)?\b|error|expected|actual|assert|✗|×|panic|traceback|exception)/i;
337
+ const SKIP = /^\s*#\s*(fail|pass|tests|suites)\s+\d+|failureType|duration_ms|^\s*code:|^\s*\.\.\.$|^\s*error:\s*\|-?\s*$|^\s*name:\s*'?AssertionError'?\s*$|^\s*operator:/i;
338
+ const hits = all.filter((x) => KEY.test(x) && !SKIP.test(x)).map((x) => x.trim()).filter((x, i, a) => a.indexOf(x) === i).slice(0, 10);
339
+ const tail = (hits.length ? hits : all.slice(-12)).join('\n');
340
+ const cantRun = !ok && (p.status === 127 || /Missing script:|command not found|is not recognized as an internal|ENOENT|No such file or directory.*(\.js|\.py)/i.test(all.join('\n')));
341
+ results.push({ id: l.id, name: l.name, ok, ms, exit: p.status, cantRun, timedOut: !!(p.error && p.error.code === 'ETIMEDOUT') });
342
+ lines.push(`${ok ? OK() : cantRun ? WARN() : BAD()} ${c.bold(l.id.padEnd(4))} ${l.name} ${c.dim(`(${(ms / 1000).toFixed(1)}s)`)}${cantRun ? c.yellow(' proof command cannot run') : ''}`);
343
+ if (!ok) lines.push(c.dim(tail.split('\n').map((x) => ' │ ' + x).join('\n')));
344
+ }
345
+ const failed = results.filter((x) => !x.ok);
346
+ const broken = failed.filter((x) => !x.cantRun), bad = failed.filter((x) => x.cantRun);
347
+ r.wjson('.cache/last-check.json', { at: new Date().toISOString(), head: r.head(), results });
348
+ lines.push('');
349
+ if (bad.length) lines.push(`${WARN()} ${c.yellow(`${bad.map((f) => f.id).join(', ')}: the proof command itself can't run (missing script or command).`)} Fix the lock: stet lock rm ${bad[0].id}, then add it with a command that works (see: npm run).`);
350
+ if (broken.length) lines.push(`${BAD()} ${c.red(`${broken.length} locked behaviour${broken.length > 1 ? 's' : ''} broken: ${broken.map((f) => f.id).join(', ')}.`)} Fix the change, not the check.`);
351
+ if (!failed.length) lines.push(`${OK()} ${c.green('All locked behaviours pass.')}`);
352
+ return out(lines.join('\n'), failed.length ? 1 : 0, { results });
353
+ }
354
+
355
+ // ---------- scope + guard ----------
356
+ function scopeSet(r, task, { allow } = {}) {
357
+ r.need();
358
+ const a = splitList(allow);
359
+ if (!task || !a.length) throw new StetError('Usage: stet scope set "<task>" --allow "src/feature/**,tests/feature/**"');
360
+ r.wjson('scope.json', { task, allow: a, base: r.head(true) || null, set: new Date().toISOString() }); sync(r);
361
+ return out(`${OK()} Scope set: ${c.bold(task)}\n allowed: ${a.map((g) => c.cyan(g)).join(', ')}\n base: ${r.head() || '(no commits)'} — guard compares everything changed since here`);
362
+ }
363
+ function scopeShow(r) {
364
+ r.need(); const s = r.scope;
365
+ if (!s.task) return out('No scope set. Set one before an AI task: stet scope set "<task>" --allow "<globs>"');
366
+ return out(`${c.bold('Task:')} ${s.task}\n${c.bold('Allowed:')} ${s.allow.join(', ')}\n${c.dim(`Set ${s.set} at ${s.base ? s.base.slice(0, 7) : 'no commit'}`)}`);
367
+ }
368
+ function scopeClear(r) { r.need(); r.wjson('scope.json', {}); sync(r); return out(`${OK()} Scope cleared.`); }
369
+
370
+ function guard(r, { base, staged, warn } = {}) {
371
+ r.need();
372
+ if (!r.isGit()) throw new StetError('stet guard needs a git repo.');
373
+ const s = r.scope, cfg = r.config, L = r.locks;
374
+ const files = changedFiles(r, { base: base || s.base, staged }).filter((f) => !matches(f, STET_OWNED));
375
+ const outScope = [], protectedHits = [], lockHits = [];
376
+ for (const f of files) {
377
+ if (s.allow && s.allow.length && !matches(f, s.allow)) outScope.push(f);
378
+ if (matches(f, cfg.protect)) protectedHits.push(f);
379
+ for (const l of L) if (matches(f, l.protect)) lockHits.push({ f, l });
380
+ }
381
+ const lines = [];
382
+ const what = staged ? 'staged files' : `changes since ${(base || s.base || 'HEAD').slice(0, 7)}`;
383
+ lines.push(c.bold(`Guarding ${files.length} changed file${files.length === 1 ? '' : 's'}`) + c.dim(` (${what})`) + (s.task ? c.dim(` · task: ${s.task}`) : ''));
384
+ if (!s.task) lines.push(`${WARN()} No scope set, so only protected paths and locks are checked. Set one: stet scope set "<task>" --allow "<globs>"`);
385
+ for (const f of files) {
386
+ const bad = outScope.includes(f) || protectedHits.includes(f);
387
+ const lk = lockHits.filter((x) => x.f === f).map((x) => x.l.id);
388
+ lines.push(` ${bad ? BAD() : lk.length ? WARN() : OK()} ${f}${outScope.includes(f) ? c.red(' out of scope') : ''}${protectedHits.includes(f) ? c.red(' protected') : ''}${lk.length ? c.yellow(` affects ${lk.join(', ')}`) : ''}`);
389
+ }
390
+ const violations = [...new Set([...outScope, ...protectedHits])];
391
+ const lockIds = [...new Set(lockHits.map((x) => x.l.id))];
392
+ lines.push('');
393
+ if (violations.length) lines.push(`${BAD()} ${c.red(`${violations.length} file${violations.length > 1 ? 's' : ''} outside scope or protected.`)} Revert, or widen the scope on purpose.`);
394
+ if (lockIds.length) lines.push(`${WARN()} Changes touch locked behaviour ${lockIds.join(', ')}. Run: stet check --only ${lockIds.join(',')}`);
395
+ if (!violations.length) lines.push(`${OK()} ${c.green(files.length ? 'All changes are inside scope.' : 'No changes yet.')}`);
396
+ return out(lines.join('\n'), violations.length && !warn ? 1 : 0, { files, violations, lockIds });
397
+ }
398
+
399
+ // ---------- context pack ----------
400
+ function readCapped(r, f, maxKB) {
401
+ try {
402
+ const full = path.join(r.root, f);
403
+ const st = fs.statSync(full);
404
+ if (!st.isFile()) return null;
405
+ const buf = fs.readFileSync(full);
406
+ if (buf.includes(0)) return '(binary file skipped)';
407
+ const s = buf.toString('utf8');
408
+ return s.length > maxKB * 1024 ? s.slice(0, maxKB * 1024) + `\n… (truncated at ${maxKB} KB)` : s;
409
+ } catch { return null; }
410
+ }
411
+ function tree(files, cap = 400) {
412
+ const shown = files.slice(0, cap);
413
+ const outL = []; let prev = [];
414
+ for (const f of shown) {
415
+ const parts = f.split('/');
416
+ let i = 0; while (i < parts.length - 1 && prev[i] === parts[i]) i++;
417
+ for (let j = i; j < parts.length - 1; j++) outL.push(' '.repeat(j) + parts[j] + '/');
418
+ outL.push(' '.repeat(parts.length - 1) + parts[parts.length - 1]);
419
+ prev = parts;
420
+ }
421
+ if (files.length > cap) outL.push(`… and ${files.length - cap} more files`);
422
+ return outL.join('\n');
423
+ }
424
+ const fence = (f) => ({ ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx', py: 'python', go: 'go', rs: 'rust', rb: 'ruby', json: 'json', md: 'md', yml: 'yaml', yaml: 'yaml', sql: 'sql', css: 'css', html: 'html', sh: 'bash' }[f.split('.').pop()] || '');
425
+ function context(r, { files, changed, diff, maxKB } = {}) {
426
+ r.need();
427
+ const cfg = r.config; maxKB = +maxKB || cfg.maxFileKB;
428
+ const all = listFiles(r);
429
+ const g = r.isGit();
430
+ const status = g ? nonEmpty(r.tgit(['status', '--short'])).filter((l) => !/\.stet\/(\.cache|context\.md)/.test(l)) : [];
431
+ const parts = [];
432
+ parts.push(`# Context pack — ${cfg.name}`);
433
+ parts.push(`Generated by Stet ${VERSION} on ${new Date().toISOString().replace('T', ' ').slice(0, 16)} UTC · branch \`${g ? r.branch() : 'n/a'}\` · commit \`${g ? r.head() || 'none' : 'n/a'}\` · ${status.length} uncommitted change${status.length === 1 ? '' : 's'} · ${all.length} files`);
434
+ parts.push(`> **For the AI reading this:** this is the live state of the repository right now. It replaces any code, file list or assumption from earlier in this conversation. Follow the working agreement below. Do not invent files that are not in the tree. If you need a file's contents, ask for it by path.`);
435
+ parts.push(agentsBlock(r).replace(BEGIN, '').replace(END, '').replace(/^<!--.*-->$/m, '').replace(/^### /gm, '#### ').replace(/^# Working agreement.*$/m, '## Working agreement').replace(/^## (?!Working)/gm, '### ').trim());
436
+ if (g && r.head()) parts.push(`## Recent commits\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate', '-15']).trim()}\n\`\`\``);
437
+ if (status.length) parts.push(`## Uncommitted changes\n\`\`\`\n${status.join('\n')}\n\`\`\``);
438
+ parts.push(`## File tree\n\`\`\`\n${tree(all)}\n\`\`\``);
439
+ let pick = [];
440
+ if (files) pick = all.filter((f) => matches(f, splitList(files)));
441
+ if (changed) pick = [...new Set([...pick, ...changedFiles(r, { base: r.scope.base }).filter((f) => !matches(f, STET_OWNED))])];
442
+ if (pick.length) {
443
+ let budget = 400 * 1024;
444
+ const blocks = [];
445
+ for (const f of pick) {
446
+ const s = readCapped(r, f, maxKB);
447
+ if (s == null) { blocks.push(`### ${f}\n(deleted or unreadable)`); continue; }
448
+ if (budget - s.length < 0) { blocks.push(`### ${f}\n(skipped: context size budget reached)`); continue; }
449
+ budget -= s.length;
450
+ blocks.push(`### ${f}\n\`\`\`${fence(f)}\n${s.replace(/\s*$/, '')}\n\`\`\``);
451
+ }
452
+ parts.push(`## File contents\n${blocks.join('\n\n')}`);
453
+ }
454
+ if (diff && g) {
455
+ let d = r.head() ? r.tgit(['diff', r.scope.base || 'HEAD']) : '';
456
+ if (d.length > 200 * 1024) d = d.slice(0, 200 * 1024) + '\n… (diff truncated)';
457
+ parts.push(`## Diff since scope was set\n\`\`\`diff\n${d.trim() || '(no changes)'}\n\`\`\``);
458
+ }
459
+ return out(parts.join('\n\n') + '\n', 0, { files: all.length, included: pick.length });
460
+ }
461
+ function copyToClipboard(text) {
462
+ const tries = process.platform === 'darwin' ? [['pbcopy', []]] : process.platform === 'win32' ? [['clip', []]] : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
463
+ for (const [cmd, args] of tries) { const p = cp.spawnSync(cmd, args, { input: text }); if (!p.error && p.status === 0) return true; }
464
+ return false;
465
+ }
466
+
467
+ // ---------- handoff ----------
468
+ function handoff(r) {
469
+ r.need();
470
+ const cfg = r.config, st = detect(r.root), all = listFiles(r), g = r.isGit() && r.head();
471
+ const ext = {}; for (const f of all) { const e = f.includes('.') ? f.split('.').pop().toLowerCase() : '(none)'; ext[e] = (ext[e] || 0) + 1; }
472
+ const top = {}; for (const f of all) { const t = f.includes('/') ? f.split('/')[0] + '/' : '(root files)'; top[t] = (top[t] || 0) + 1; }
473
+ const envEx = ['.env.example', '.env.sample', '.env.template'].map((f) => r.rootFile(f)).find((x) => x != null);
474
+ const envKeys = envEx ? nonEmpty(envEx).filter((l) => /^[A-Z_][A-Z0-9_]*=/.test(l.trim())).map((l) => l.trim().split('=')[0]) : [];
475
+ const hot = {};
476
+ if (g) for (const f of names(r.tgit(['log', '-200', '--name-only', '--pretty=format:']))) if (all.includes(f) && !matches(f, STET_OWNED)) hot[f] = (hot[f] || 0) + 1;
477
+ const charter = r.read('charter.md').replace(/<!--[\s\S]*?-->\n?/g, '').replace(/^# .*\n+/, '').replace(/^## /gm, '### ').trim();
478
+ const ds = decisionsList(r);
479
+ const decMd = r.read('decisions.md').split(/\n(?=## D-)/).slice(1).map((b) => b.replace(/^## /, '#### ').trim()).join('\n\n');
480
+ const L = r.locks, s = r.scope;
481
+ const sorted = (o, n) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n);
482
+ const md = `# Handoff — ${cfg.name}
483
+
484
+ Generated by Stet ${VERSION} on ${today()}. Regenerate any time with \`stet handoff\`; it is built from the repo, not from memory.
485
+
486
+ | | |
487
+ |---|---|
488
+ | Repository | ${g ? (r.tgit(['remote', 'get-url', 'origin']).trim() || 'no remote set') : 'not a git repo'} |
489
+ | Branch / commit | ${g ? `\`${r.branch()}\` @ \`${r.head()}\`` : '—'} |
490
+ | History | ${g ? `${r.tgit(['rev-list', '--count', 'HEAD']).trim()} commits since ${r.tgit(['log', '--reverse', '--format=%as']).split('\n')[0]} · ${nonEmpty(r.tgit(['shortlog', '-sn', 'HEAD'])).length} contributor(s)` : '—'} |
491
+ | Stack | ${[...st.langs, ...st.frameworks].join(', ') || 'not detected'} |
492
+ | Size | ${all.length} tracked files |
493
+
494
+ ## 1. Product charter
495
+ ${charter}
496
+
497
+ ## 2. Run it
498
+ ${st.run.length || st.test ? [...st.run, ...(st.test ? [st.test + ' # tests'] : [])].map((x) => '- `' + x + '`').join('\n') : '- No run scripts detected. Add them to the charter.'}
499
+ ${envKeys.length ? `\nEnvironment variables needed (names only, from the example env file):\n${envKeys.map((k) => '`' + k + '`').join(' · ')}` : ''}
500
+
501
+ ## 3. Architecture at a glance
502
+ **Entry points:** ${st.entry.length ? st.entry.map((x) => '`' + x + '`').join(', ') : 'not detected'}
503
+
504
+ **Top-level layout**
505
+ | Folder | Files |
506
+ |---|---|
507
+ ${sorted(top, 20).map(([k, v]) => `| \`${k}\` | ${v} |`).join('\n')}
508
+
509
+ **Languages by file count:** ${sorted(ext, 8).map(([k, v]) => `${k} ${v}`).join(' · ')}
510
+ ${st.deps.length ? `\n**Key dependencies:** ${st.deps.slice(0, 30).map((x) => '`' + x + '`').join(', ')}` : ''}
511
+ ${Object.keys(hot).length ? `\n**Most-changed files (last 200 commits)** — where the active work and the risk are:\n${sorted(hot, 10).map(([k, v]) => `- \`${k}\` — ${v} change${v === 1 ? '' : 's'}`).join('\n')}` : ''}
512
+
513
+ ## 4. What must not break
514
+ ${L.length ? `| ID | Behaviour | Proof | Watches |\n|---|---|---|---|\n${L.map((l) => `| ${l.id} | ${l.name} | \`${l.cmd}\` | ${(l.protect || []).join(', ') || '—'} |`).join('\n')}\n\nRun them all with \`stet check\`.` : 'No locked behaviours recorded yet.'}
515
+ ${cfg.protect.length ? `\n**Protected paths:** ${cfg.protect.map((x) => '`' + x + '`').join(', ')}` : ''}
516
+
517
+ ## 5. Decisions and why (${ds.length})
518
+ ${decMd || 'None recorded.'}
519
+
520
+ ## 6. Work in progress
521
+ ${s.task ? `Current task: **${s.task}** (allowed: ${s.allow.map((x) => '`' + x + '`').join(', ')})` : 'No task scope is open.'}
522
+ ${g ? `\nRecent commits:\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate', '-12']).trim()}\n\`\`\`` : ''}
523
+
524
+ ## 7. First day checklist for a new engineer or CTO
525
+ - [ ] Read sections 1, 4 and 5 of this document (charter, what must not break, decisions).
526
+ - [ ] Get it running locally with the commands in section 2.
527
+ - [ ] Run \`stet check\` and confirm every locked behaviour passes on your machine.
528
+ - [ ] Read \`AGENTS.md\`. It is what the AI agents follow, so it is also the team's working agreement.
529
+ - [ ] Skim the most-changed files in section 3.
530
+ - [ ] Note anything here that is wrong or missing and record it with \`stet decide\` or in the charter.
531
+ `;
532
+ return out(md);
533
+ }
534
+
535
+ // ---------- status ----------
536
+ function status(r) {
537
+ r.need();
538
+ const cfg = r.config, s = r.scope, L = r.locks, ds = decisionsList(r);
539
+ const last = r.json('.cache/last-check.json', null);
540
+ const lines = [c.bold(`Stet · ${cfg.name}`) + c.dim(` ${r.isGit() ? r.branch() + ' @ ' + (r.head() || 'no commits') : 'not a git repo'}`)];
541
+ lines.push(` scope ${s.task ? s.task + c.dim(' (' + s.allow.join(', ') + ')') : c.dim('none set')}`);
542
+ lines.push(` locks ${L.length}${last ? ` last check ${last.results.every((x) => x.ok) ? c.green('passed') : c.red('failed: ' + last.results.filter((x) => !x.ok).map((x) => x.id).join(', '))} ${c.dim(last.at.slice(0, 16).replace('T', ' ') + (last.head && last.head !== r.head() ? ' (older commit)' : ''))}` : c.dim(' never checked')}`);
543
+ lines.push(` decisions ${ds.length}${ds.length ? c.dim(' latest: ' + ds[ds.length - 1].title) : ''}`);
544
+ const ag = r.rootFile('AGENTS.md') || '';
545
+ lines.push(` AGENTS.md ${ag.includes(agentsBlock(r)) ? c.green('in sync') : c.yellow('out of date — run stet sync')}`);
546
+ if (r.isGit() && s.task) { const gd = guard(r, { warn: true }); lines.push(` guard ${gd.data.violations.length ? c.red(gd.data.violations.length + ' out-of-scope file(s)') : c.green(gd.data.files.length + ' changed, all in scope')}`); }
547
+ return out(lines.join('\n'));
548
+ }
549
+
550
+ // ---------- git hook ----------
551
+ function hook(r, { uninstall, withCheck } = {}) {
552
+ r.need();
553
+ const gitDir = r.tgit(['rev-parse', '--git-path', 'hooks']).trim();
554
+ if (!gitDir) throw new StetError('Not a git repo.');
555
+ const hp = path.resolve(r.root, gitDir, 'pre-commit');
556
+ const BEG = '# >>> stet >>>', FIN = '# <<< stet <<<';
557
+ let cur = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : '';
558
+ if (cur.includes(BEG)) cur = cur.slice(0, cur.indexOf(BEG)) + cur.slice(cur.indexOf(FIN) + FIN.length + 1);
559
+ if (uninstall) { fs.writeFileSync(hp, cur); return out(`${OK()} Removed Stet from the pre-commit hook.`); }
560
+ const block = `${BEG}
561
+ if command -v stet >/dev/null 2>&1; then STET="stet"; else STET="npx --no-install stet"; fi
562
+ $STET guard --staged || { echo "stet: commit blocked. Out-of-scope or protected files are staged. Bypass on purpose with: git commit --no-verify"; exit 1; }
563
+ ${withCheck ? '$STET check || { echo "stet: commit blocked. A locked behaviour is failing."; exit 1; }\n' : 'if [ "$STET_CHECK" = "1" ]; then $STET check || exit 1; fi\n'}${FIN}
564
+ `;
565
+ const next = (cur.trim() ? cur.replace(/\s*$/, '\n') : '#!/bin/sh\n') + block;
566
+ fs.mkdirSync(path.dirname(hp), { recursive: true });
567
+ fs.writeFileSync(hp, next); fs.chmodSync(hp, 0o755);
568
+ return out(`${OK()} Pre-commit hook installed: blocks commits that stage out-of-scope or protected files${withCheck ? ' and runs stet check' : ' (set STET_CHECK=1 to also run stet check)'}.`);
569
+ }
570
+
571
+ // ---------- MCP config ----------
572
+ function mcpInstall(r) {
573
+ const fp = path.join(r.root, '.mcp.json');
574
+ let j = {}; try { j = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch {}
575
+ j.mcpServers = { ...(j.mcpServers || {}), stet: { command: 'npx', args: ['-y', 'stet-cli', 'mcp'] } };
576
+ fs.writeFileSync(fp, JSON.stringify(j, null, 2) + '\n');
577
+ return out(`${OK()} Wrote .mcp.json. Claude Code picks up the Stet tools in this project.\n Cursor: add the same "stet" entry to .cursor/mcp.json.\n Anywhere else: command \`npx -y stet-cli mcp\` (stdio).`);
578
+ }
579
+
580
+ module.exports = { VERSION, Repo, StetError, findRoot, setColor, c, init, sync, decide, decisions, lockAdd, lockList, lockRm, check, scopeSet, scopeShow, scopeClear, guard, context, copyToClipboard, handoff, status, hook, mcpInstall, globRe, matches, detect, listFiles, changedFiles };
package/lib/mcp.js ADDED
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+ // Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0). No dependencies.
3
+ const core = require('./core');
4
+
5
+ const TOOLS = [
6
+ { name: 'stet_context', description: 'Get the live state of this repository: working agreement (charter, scope, locked behaviours, decisions), recent commits, uncommitted changes and the file tree. Call this at the start of every task and whenever you are unsure what exists. Optionally include file contents.',
7
+ inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'Paths or globs whose contents to include, e.g. ["src/auth/**"]' }, changed: { type: 'boolean', description: 'Include contents of files changed since the scope was set' }, diff: { type: 'boolean', description: 'Include the diff since the scope was set' } } } },
8
+ { name: 'stet_guard', description: 'Check that every file changed so far is inside the agreed task scope and outside protected paths. Run after making changes and before reporting a task as done.', inputSchema: { type: 'object', properties: {} } },
9
+ { name: 'stet_check', description: 'Run the locked behaviours (the commands that prove existing features still work). Run before reporting a task as done. If one fails, fix your change; never edit the test or weaken the check.', inputSchema: { type: 'object', properties: { only: { type: 'array', items: { type: 'string' }, description: 'Lock IDs to run, e.g. ["L2"]. Default: all.' } } } },
10
+ { name: 'stet_scope', description: 'Read the current task scope: the task and the file globs you may change. Only the human can change the scope.', inputSchema: { type: 'object', properties: {} } },
11
+ { name: 'stet_decide', description: 'Record an architectural or product decision in the decision log so future sessions and teammates do not reopen it.', inputSchema: { type: 'object', properties: { decision: { type: 'string' }, why: { type: 'string' } }, required: ['decision'] } },
12
+ ];
13
+
14
+ function callTool(name, a = {}) {
15
+ const r = new core.Repo(core.findRoot(process.env.STET_ROOT || process.cwd()));
16
+ switch (name) {
17
+ case 'stet_context': return core.context(r, { files: a.files, changed: a.changed, diff: a.diff });
18
+ case 'stet_guard': return core.guard(r, {});
19
+ case 'stet_check': return core.check(r, { only: a.only });
20
+ case 'stet_scope': return core.scopeShow(r);
21
+ case 'stet_decide': return core.decide(r, a.decision, { why: a.why });
22
+ default: throw new core.StetError('Unknown tool: ' + name);
23
+ }
24
+ }
25
+
26
+ function serve(input = process.stdin, output = process.stdout) {
27
+ core.setColor(false);
28
+ const send = (m) => output.write(JSON.stringify(m) + '\n');
29
+ let buf = '';
30
+ input.setEncoding('utf8');
31
+ input.on('data', (chunk) => {
32
+ buf += chunk;
33
+ let i;
34
+ while ((i = buf.indexOf('\n')) >= 0) {
35
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
36
+ if (!line) continue;
37
+ let msg; try { msg = JSON.parse(line); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); continue; }
38
+ handle(msg, send);
39
+ }
40
+ });
41
+ }
42
+
43
+ function handle(msg, send) {
44
+ const { id, method, params = {} } = msg;
45
+ const reply = (result) => id !== undefined && send({ jsonrpc: '2.0', id, result });
46
+ const fail = (code, message) => id !== undefined && send({ jsonrpc: '2.0', id, error: { code, message } });
47
+ try {
48
+ if (method === 'initialize') return reply({ protocolVersion: params.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'stet', version: core.VERSION }, instructions: 'Stet keeps you inside the human\'s agreed scope. Call stet_context at the start of a task, stet_guard and stet_check before saying it is done.' });
49
+ if (method === 'ping') return reply({});
50
+ if (method === 'tools/list') return reply({ tools: TOOLS });
51
+ if (method === 'tools/call') {
52
+ try { const r = callTool(params.name, params.arguments || {}); return reply({ content: [{ type: 'text', text: r.text }], isError: r.code !== 0 }); }
53
+ catch (e) { return reply({ content: [{ type: 'text', text: 'stet: ' + e.message }], isError: true }); }
54
+ }
55
+ if (method && method.startsWith('notifications/')) return;
56
+ return fail(-32601, 'Method not found: ' + method);
57
+ } catch (e) { return fail(-32603, e.message); }
58
+ }
59
+
60
+ module.exports = { serve, handle, TOOLS };
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "stet-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Keep AI coding agents from breaking what already works. Charter, locked behaviours, scope guard, decision log and git-synced context for Claude Code, Cursor, Codex and any chat.",
5
+ "bin": {
6
+ "stet": "bin/stet.js"
7
+ },
8
+ "main": "lib/core.js",
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "ai",
24
+ "agents",
25
+ "claude-code",
26
+ "cursor",
27
+ "codex",
28
+ "mcp",
29
+ "agents.md",
30
+ "regression",
31
+ "context",
32
+ "cli"
33
+ ],
34
+ "license": "MIT",
35
+ "author": "Shamaz Khan",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/shamazkhan/stet.git"
39
+ },
40
+ "homepage": "https://github.com/shamazkhan/stet#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/shamazkhan/stet/issues"
43
+ }
44
+ }