stet-cli 0.1.2 → 0.2.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/CHANGELOG.md +11 -0
- package/README.md +12 -6
- package/bin/stet.js +30 -4
- package/lib/core.js +214 -48
- package/lib/mcp.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-11-14
|
|
4
|
+
- New `stet pr`: writes the commit message and PR description from facts — the diff since the scope was set, the ticket ID, scope result, `stet check` results and decisions logged during the task. `--check` runs the locks first, `--base main` compares against a branch.
|
|
5
|
+
- Ticket IDs: `stet scope set "LIN-142 Add CSV export" …` (or `--ticket LIN-142`) stores the ticket. It appears in AGENTS.md, context packs, guard output and PR text.
|
|
6
|
+
- `stet hook install` now also adds a prepare-commit-msg hook that prefixes commit messages with the ticket ID ("LIN-142: …"), never twice.
|
|
7
|
+
- New MCP tool `stet_pr` so Claude Code and Cursor can draft the PR text.
|
|
8
|
+
- The pre-commit hook falls back to `npx stet-cli` when stet isn't installed globally.
|
|
9
|
+
|
|
10
|
+
## 0.1.3 — 2026-11-14
|
|
11
|
+
- Stack detection now finds projects in subfolders (e.g. backend/, frontend/, apps/web) and languages from file types, so monorepos with Python + JavaScript are detected correctly.
|
|
12
|
+
- stet init creates one test lock per sub-project (e.g. `cd backend && pytest -q`).
|
|
13
|
+
|
|
3
14
|
## 0.1.2 — 2026-11-13
|
|
4
15
|
- Published on npm as stet-cli (the command is still `stet`).
|
|
5
16
|
|
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Stet is a small, free command-line tool that gives every AI coding tool (Claude
|
|
|
9
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
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
11
|
- **Decision log.** `stet decide` records "we chose X because Y", so the next session doesn't reopen it.
|
|
12
|
+
- **Ticket-aware PRs.** Put the ticket ID in the scope (`LIN-142 Add CSV export`) and `stet pr` writes the commit message and PR description from the real diff, scope, check results and decisions. The git hook adds the ticket ID to every commit.
|
|
12
13
|
- **Handoff pack.** `stet handoff` writes an onboarding document for a new developer or CTO, built from the repo.
|
|
13
14
|
- **MCP server.** `stet mcp` exposes all of this as tools to Claude Code, Cursor and any MCP client.
|
|
14
15
|
|
|
@@ -43,13 +44,17 @@ Commit `.stet/`, `AGENTS.md` and `CLAUDE.md`. Every agent and teammate then work
|
|
|
43
44
|
# once: lock what already works
|
|
44
45
|
stet lock add "UAE VAT maths is correct" --cmd "node --test test/vat.test.js" --protect "src/vat/**"
|
|
45
46
|
|
|
46
|
-
# before each AI task
|
|
47
|
-
stet scope set "Add CSV export of invoices" --allow "src/export/**,test/export*"
|
|
47
|
+
# before each AI task (the ticket ID is optional)
|
|
48
|
+
stet scope set "LIN-142 Add CSV export of invoices" --allow "src/export/**,test/export*"
|
|
48
49
|
stet context --files "src/invoices/**" # copied to clipboard; paste into your chat
|
|
49
50
|
|
|
50
51
|
# after the AI says it's done
|
|
51
52
|
stet guard # anything outside scope or in protected paths?
|
|
52
53
|
stet check # do all locked behaviours still pass?
|
|
54
|
+
|
|
55
|
+
# ship it
|
|
56
|
+
stet pr # commit message + PR description (PR copied to clipboard)
|
|
57
|
+
git commit -F .stet/.cache/commit.txt # "LIN-142: Add CSV export of invoices" + file list
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
Example (real output from a sample accounting app):
|
|
@@ -81,8 +86,8 @@ Checking 2 locked behaviours
|
|
|
81
86
|
|---|---|
|
|
82
87
|
| `stet init [--name X] [--mcp]` | Set up `.stet/`, `AGENTS.md` and `CLAUDE.md`. `--mcp` also writes `.mcp.json`. Safe to re-run. |
|
|
83
88
|
| `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. |
|
|
89
|
+
| `stet scope set "<task>" --allow "<globs>" [--ticket LIN-142]` | Agree the task and the files it may touch. Records the current commit as the base. A ticket ID at the start of the task is picked up automatically. |
|
|
90
|
+
| `stet scope [show\|clear\|ticket]` | Show or clear the scope, or print just the ticket ID. |
|
|
86
91
|
| `stet guard [--staged] [--base ref] [--warn]` | Check changes since the scope base (committed, staged, unstaged and untracked). Exits 1 on violations unless `--warn`. |
|
|
87
92
|
| `stet lock add "<behaviour>" --cmd "<cmd>" [--protect "<globs>"]` | Lock a behaviour. `--protect` tells guard which files affect it. |
|
|
88
93
|
| `stet lock [list\|rm L2]` | List locks with their last result, or remove one. |
|
|
@@ -90,8 +95,9 @@ Checking 2 locked behaviours
|
|
|
90
95
|
| `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
96
|
| `stet decide "<decision>" [--why "<reason>"]` | Add to the decision log. |
|
|
92
97
|
| `stet decisions [--all]` | List decisions. |
|
|
98
|
+
| `stet pr [--base main] [--check] [--stdout]` | Write `.stet/.cache/commit.txt` and `.stet/.cache/pr.md` from the diff since the scope was set: ticket, changed files, scope result, `stet check` results and decisions logged during the task. `--check` runs the locks first. |
|
|
93
99
|
| `stet handoff [--out HANDOFF.md] [--stdout]` | Generate the onboarding pack. |
|
|
94
|
-
| `stet hook install [--check]` | Add
|
|
100
|
+
| `stet hook install [--check]` | Add git hooks: pre-commit blocks commits that stage out-of-scope or protected files (`--check` also runs `stet check`); prepare-commit-msg adds the ticket ID to commit messages. |
|
|
95
101
|
| `stet status` | One-screen summary. |
|
|
96
102
|
| `stet mcp` / `stet mcp install` | Run the MCP server over stdio, or write `.mcp.json`. |
|
|
97
103
|
|
|
@@ -111,7 +117,7 @@ Add `--json` to any command for machine-readable output.
|
|
|
111
117
|
|
|
112
118
|
**ChatGPT, Claude.ai, Gemini (chat):** run `stet context --files "<what you're working on>"` and paste the result.
|
|
113
119
|
|
|
114
|
-
MCP tools: `stet_context`, `stet_guard`, `stet_check`, `stet_scope` (read-only; only the human sets scope) and `stet_decide`.
|
|
120
|
+
MCP tools: `stet_context`, `stet_guard`, `stet_check`, `stet_scope` (read-only; only the human sets scope), `stet_pr` and `stet_decide`.
|
|
115
121
|
|
|
116
122
|
## Configuration
|
|
117
123
|
|
package/bin/stet.js
CHANGED
|
@@ -28,8 +28,9 @@ ${c.bold('Setup')}
|
|
|
28
28
|
stet sync Regenerate AGENTS.md from .stet/
|
|
29
29
|
|
|
30
30
|
${c.bold('Before an AI task')}
|
|
31
|
-
stet scope set "<task>" --allow "src/x/**,tests/x/**"
|
|
32
|
-
|
|
31
|
+
stet scope set "<task>" --allow "src/x/**,tests/x/**" [--ticket LIN-142]
|
|
32
|
+
A ticket ID at the start of the task ("LIN-142 Add export") is picked up too
|
|
33
|
+
stet scope [show|clear|ticket]
|
|
33
34
|
stet context [--files "glob,glob"] [--changed] [--diff] [--copy|--stdout|--out file]
|
|
34
35
|
Git-synced context pack for any AI chat (no more uploading src/)
|
|
35
36
|
|
|
@@ -37,6 +38,11 @@ ${c.bold('After an AI task')}
|
|
|
37
38
|
stet guard [--staged] [--base ref] [--warn] Flag changes outside scope or in protected paths
|
|
38
39
|
stet check [--only L1,L2] Run locked behaviours; non-zero exit if any broke
|
|
39
40
|
|
|
41
|
+
${c.bold('Shipping')}
|
|
42
|
+
stet pr [--base main] [--check] [--stdout]
|
|
43
|
+
Commit message + PR description from the diff, scope, checks and decisions
|
|
44
|
+
Then: git commit -F .stet/.cache/commit.txt
|
|
45
|
+
|
|
40
46
|
${c.bold('Memory')}
|
|
41
47
|
stet lock add "<behaviour>" --cmd "<proof command>" [--protect "glob,glob"]
|
|
42
48
|
stet lock [list|rm L2]
|
|
@@ -77,9 +83,10 @@ function main() {
|
|
|
77
83
|
case 'check': res = core.check(r, { only: fl.only }); break;
|
|
78
84
|
case 'scope':
|
|
79
85
|
if (!sub || sub === 'show') res = core.scopeShow(r);
|
|
80
|
-
else if (sub === 'set') res = core.scopeSet(r, rest.join(' '), { allow: fl.allow });
|
|
86
|
+
else if (sub === 'set') res = core.scopeSet(r, rest.join(' '), { allow: fl.allow, ticket: fl.ticket });
|
|
87
|
+
else if (sub === 'ticket') res = core.scopeTicket(r);
|
|
81
88
|
else if (sub === 'clear') res = core.scopeClear(r);
|
|
82
|
-
else throw new core.StetError('Usage: stet scope [show|set|clear]');
|
|
89
|
+
else throw new core.StetError('Usage: stet scope [show|set|clear|ticket]');
|
|
83
90
|
break;
|
|
84
91
|
case 'guard': res = core.guard(r, { staged: fl.staged, base: fl.base, warn: fl.warn }); break;
|
|
85
92
|
case 'context': case 'ctx': {
|
|
@@ -102,6 +109,25 @@ function main() {
|
|
|
102
109
|
res = { code: 0, text: `${c.green('✓')} Handoff pack written to ${c.bold(path.relative(process.cwd(), file) || file)}` };
|
|
103
110
|
break;
|
|
104
111
|
}
|
|
112
|
+
case 'pr': {
|
|
113
|
+
core.setColor(false);
|
|
114
|
+
const p = core.pr(r, { base: fl.base, runCheck: fl.check });
|
|
115
|
+
core.setColor((process.stdout.isTTY || process.env.STET_COLOR === '1') && !process.env.NO_COLOR && !fl['no-color']);
|
|
116
|
+
if (fl.stdout) { process.stdout.write(p.text); return; }
|
|
117
|
+
const copied = core.copyToClipboard(p.data.pr);
|
|
118
|
+
const warn = [];
|
|
119
|
+
if (p.data.violations.length) warn.push(c.yellow(`! ${p.data.violations.length} file(s) outside scope or protected — listed in the PR text.`));
|
|
120
|
+
if (!p.data.checks) warn.push(c.yellow('! stet check has not been run for this change. Run it, or use stet pr --check.'));
|
|
121
|
+
else if (p.data.checks.some((x) => !x.ok)) warn.push(c.red('✗ A locked behaviour is failing — listed in the PR text.'));
|
|
122
|
+
res = { code: 0, text: [
|
|
123
|
+
`${c.green('✓')} ${c.bold(p.data.title)} ${c.dim(`(${p.data.files} files)`)}`,
|
|
124
|
+
` commit message .stet/.cache/commit.txt → ${c.cyan('git commit -F .stet/.cache/commit.txt')}`,
|
|
125
|
+
` PR description .stet/.cache/pr.md → ${copied ? 'copied to clipboard' : 'open the file, or run: stet pr --stdout'}`,
|
|
126
|
+
...warn.map((w) => ' ' + w),
|
|
127
|
+
c.dim(' Fill in the "What and why" line (or paste the PR text into your AI and ask it to write it).'),
|
|
128
|
+
].join('\n') };
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
105
131
|
case 'status': case 'st': res = core.status(r); break;
|
|
106
132
|
case 'hook':
|
|
107
133
|
if (sub === 'install') res = core.hook(r, { withCheck: fl.check });
|
package/lib/core.js
CHANGED
|
@@ -77,37 +77,83 @@ function matches(file, globs) {
|
|
|
77
77
|
const splitList = (v) => (Array.isArray(v) ? v : String(v || '').split(',')).map((s) => s.trim()).filter(Boolean);
|
|
78
78
|
|
|
79
79
|
// ---------- stack detection ----------
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', 'coverage', '__pycache__', '.venv', 'venv', 'env', 'vendor', 'target', '.stet', '.idea', '.vscode']);
|
|
81
|
+
const MANIFESTS = ['package.json', 'pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile', 'go.mod', 'Cargo.toml', 'Gemfile', 'pom.xml', 'build.gradle', 'composer.json', 'pubspec.yaml'];
|
|
82
|
+
const EXT_LANG = { py: 'Python', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', go: 'Go', rs: 'Rust', rb: 'Ruby', java: 'Java', kt: 'Kotlin', swift: 'Swift', php: 'PHP', cs: 'C#', dart: 'Dart' };
|
|
83
|
+
|
|
84
|
+
// Folders (root + up to 2 levels deep) that contain a project manifest, e.g. "", "backend", "apps/web".
|
|
85
|
+
function projectDirs(root) {
|
|
86
|
+
const out = [];
|
|
87
|
+
const walk = (rel, depth) => {
|
|
88
|
+
const abs = path.join(root, rel);
|
|
89
|
+
let entries = [];
|
|
90
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
91
|
+
if (entries.some((e) => e.isFile() && MANIFESTS.includes(e.name))) out.push(rel);
|
|
92
|
+
if (depth >= 2) return;
|
|
93
|
+
for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(rel ? rel + '/' + e.name : e.name, depth + 1);
|
|
94
|
+
};
|
|
95
|
+
walk('', 0);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function detectIn(root, rel, s) {
|
|
100
|
+
const ex = (f) => fs.existsSync(path.join(root, rel, f));
|
|
101
|
+
const rd = (f) => { try { return fs.readFileSync(path.join(root, rel, f), 'utf8'); } catch { return ''; } };
|
|
102
|
+
const where = rel ? ` (${rel}/)` : '';
|
|
103
|
+
const cd = (cmd) => (rel ? `cd ${rel} && ${cmd}` : cmd);
|
|
104
|
+
const addLang = (l) => { if (!s.langs.includes(l + where)) s.langs.push(l + where); };
|
|
105
|
+
const addFw = (f) => { if (!s.frameworks.includes(f)) s.frameworks.push(f); };
|
|
106
|
+
const addTest = (cmd) => { if (!s.tests.includes(cd(cmd))) s.tests.push(cd(cmd)); };
|
|
107
|
+
const p = (f) => (rel ? rel + '/' + f : f);
|
|
84
108
|
if (ex('package.json')) {
|
|
85
109
|
let pj = {}; try { pj = JSON.parse(rd('package.json')); } catch {}
|
|
86
110
|
const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}) };
|
|
87
111
|
s.deps.push(...Object.keys(pj.dependencies || {}));
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
112
|
+
const emptyWorkspaceRoot = !rel && !Object.keys(deps).length && !Object.keys(pj.scripts || {}).length;
|
|
113
|
+
if (!emptyWorkspaceRoot) addLang(deps.typescript || ex('tsconfig.json') ? 'TypeScript' : 'JavaScript');
|
|
114
|
+
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]) addFw(n);
|
|
115
|
+
const rootEx = (f) => ex(f) || fs.existsSync(path.join(root, f));
|
|
116
|
+
const pm = rootEx('pnpm-lock.yaml') ? 'pnpm' : rootEx('yarn.lock') ? 'yarn' : rootEx('bun.lockb') || rootEx('bun.lock') ? 'bun' : 'npm';
|
|
91
117
|
const sc = pj.scripts || {};
|
|
92
|
-
if (sc.test && !/no test specified/.test(sc.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)));
|
|
118
|
+
if (sc.test && !/no test specified/.test(sc.test)) addTest(`${pm} test`);
|
|
119
|
+
for (const k of ['dev', 'start', 'build', 'lint', 'typecheck']) if (sc[k]) s.run.push(cd(`${pm} run ${k}`));
|
|
120
|
+
if (pj.main) s.entry.push(p(pj.main));
|
|
121
|
+
if (pj.bin) s.entry.push(...(typeof pj.bin === 'string' ? [pj.bin] : Object.values(pj.bin)).map(p));
|
|
122
|
+
}
|
|
123
|
+
if (ex('pyproject.toml') || ex('requirements.txt') || ex('setup.py') || ex('Pipfile')) {
|
|
124
|
+
addLang('Python');
|
|
125
|
+
const txt = (rd('pyproject.toml') + '\n' + rd('requirements.txt') + '\n' + rd('Pipfile')).toLowerCase();
|
|
126
|
+
for (const [k, n] of [['django', 'Django'], ['fastapi', 'FastAPI'], ['flask', 'Flask'], ['sqlalchemy', 'SQLAlchemy'], ['pydantic', 'Pydantic'], ['pandas', 'pandas'], ['celery', 'Celery']]) if (txt.includes(k)) addFw(n);
|
|
127
|
+
s.deps.push(...nonEmpty(rd('requirements.txt')).filter((l) => !l.trim().startsWith('#') && !l.trim().startsWith('-')).map((l) => l.trim().split(/[=<>~! ;\[]/)[0]));
|
|
128
|
+
if (ex('tests') || ex('test') || ex('pytest.ini') || ex('conftest.py') || txt.includes('[tool.pytest')) addTest('pytest -q');
|
|
129
|
+
if (ex('manage.py')) { s.run.push(cd('python manage.py runserver')); s.entry.push(p('manage.py')); }
|
|
96
130
|
}
|
|
97
|
-
if (ex('
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
if (ex('go.mod')) { addLang('Go'); addTest('go test ./...'); }
|
|
132
|
+
if (ex('Cargo.toml')) { addLang('Rust'); addTest('cargo test'); }
|
|
133
|
+
if (ex('Gemfile')) addLang('Ruby');
|
|
134
|
+
if (ex('pom.xml') || ex('build.gradle')) addLang('Java');
|
|
135
|
+
if (ex('composer.json')) addLang('PHP');
|
|
136
|
+
if (ex('pubspec.yaml')) addLang('Dart');
|
|
137
|
+
if (ex('Dockerfile') || ex('docker-compose.yml') || ex('docker-compose.yaml') || ex('compose.yaml')) addFw('Docker');
|
|
138
|
+
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/main.py', 'app/page.tsx', 'src/main.tsx', 'main.go', 'src/main.rs']) if (ex(f) && !s.entry.includes(p(f))) s.entry.push(p(f));
|
|
139
|
+
if (!rel && 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]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function detect(root) {
|
|
143
|
+
const s = { langs: [], frameworks: [], tests: [], test: null, run: [], deps: [], entry: [] };
|
|
144
|
+
for (const rel of projectDirs(root)) detectIn(root, rel, s);
|
|
145
|
+
if (!s.frameworks.includes('Docker') && ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'compose.yml'].some((f) => fs.existsSync(path.join(root, f)))) s.frameworks.push('Docker');
|
|
146
|
+
// Fallback: languages with real source files but no manifest we recognise.
|
|
147
|
+
const files = names(tgit(['ls-files', '--cached', '--others', '--exclude-standard'], root));
|
|
148
|
+
const count = {};
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
if (f.split('/').some((seg) => SKIP_DIRS.has(seg))) continue;
|
|
151
|
+
const l = EXT_LANG[f.split('.').pop().toLowerCase()];
|
|
152
|
+
if (l) count[l] = (count[l] || 0) + 1;
|
|
104
153
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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]));
|
|
154
|
+
for (const [l, n] of Object.entries(count)) if (n >= 3 && !s.langs.some((x) => x.split(' (')[0] === l)) s.langs.push(l);
|
|
155
|
+
s.deps = [...new Set(s.deps.filter(Boolean))];
|
|
156
|
+
s.test = s.tests.length ? s.tests.join(' && ') : null;
|
|
111
157
|
return s;
|
|
112
158
|
}
|
|
113
159
|
|
|
@@ -155,7 +201,7 @@ ${name} is ...
|
|
|
155
201
|
|
|
156
202
|
## Stack
|
|
157
203
|
- ${stack}
|
|
158
|
-
${st.
|
|
204
|
+
${st.tests.length ? `- Tests: ${st.tests.map((x) => '`' + x + '`').join(', ')}\n` : ''}${st.run.length ? `- Run: ${st.run.map((x) => '`' + x + '`').join(', ')}\n` : ''}
|
|
159
205
|
## Always
|
|
160
206
|
- Read this charter, the current scope and the locked behaviours before changing code.
|
|
161
207
|
- Make the smallest change that solves the task. Match the existing style and patterns.
|
|
@@ -187,7 +233,7 @@ function decisionsList(r) {
|
|
|
187
233
|
function scopeBlock(r) {
|
|
188
234
|
const s = r.scope;
|
|
189
235
|
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.`;
|
|
236
|
+
return `${s.ticket ? `**Ticket:** ${s.ticket}\n` : ''}**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
237
|
}
|
|
192
238
|
function locksBlock(r) {
|
|
193
239
|
const L = r.locks;
|
|
@@ -253,7 +299,10 @@ function init(r, { name, mcp } = {}) {
|
|
|
253
299
|
const put = (f, s) => { if (fs.existsSync(r.p(f))) kept.push(f); else { r.write(f, s); made.push(f); } };
|
|
254
300
|
put('config.json', JSON.stringify({ name: nm, version: 1, protect: [], ignore: [], maxFileKB: 64, checkTimeoutSec: 600 }, null, 2) + '\n');
|
|
255
301
|
put('charter.md', charterTemplate(nm, st));
|
|
256
|
-
put('locks.json', JSON.stringify(st.
|
|
302
|
+
put('locks.json', JSON.stringify(st.tests.map((cmd, i) => {
|
|
303
|
+
const m = cmd.match(/^cd (\S+) && /);
|
|
304
|
+
return { id: 'L' + (i + 1), name: m ? `Test suite passes (${m[1]}/)` : 'Full test suite passes', cmd, protect: [], added: today() };
|
|
305
|
+
}), null, 2) + '\n');
|
|
257
306
|
put('scope.json', '{}\n');
|
|
258
307
|
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
308
|
put('.gitignore', '.cache/\ncontext.md\n');
|
|
@@ -353,18 +402,28 @@ function check(r, { only } = {}) {
|
|
|
353
402
|
}
|
|
354
403
|
|
|
355
404
|
// ---------- scope + guard ----------
|
|
356
|
-
|
|
405
|
+
// Ticket IDs look like LIN-142, ENG-7, PROJ-1203 (Linear, Jira, Shortcut style).
|
|
406
|
+
const TICKET_RE = /\b([A-Z][A-Z0-9]{1,9}-\d+)\b/;
|
|
407
|
+
function scopeSet(r, task, { allow, ticket } = {}) {
|
|
357
408
|
r.need();
|
|
358
409
|
const a = splitList(allow);
|
|
359
|
-
if (!task || !a.length) throw new StetError('Usage: stet scope set "<task>" --allow "src/feature/**,tests/feature/**"');
|
|
360
|
-
|
|
361
|
-
|
|
410
|
+
if (!task || !a.length) throw new StetError('Usage: stet scope set "<task>" --allow "src/feature/**,tests/feature/**" [--ticket LIN-142]');
|
|
411
|
+
// Ticket: explicit --ticket wins, otherwise taken from the task text ("LIN-142 Add CSV export").
|
|
412
|
+
let t = typeof ticket === 'string' ? ticket.trim().toUpperCase() : (task.match(TICKET_RE) || [])[1] || null;
|
|
413
|
+
if (t && !TICKET_RE.test(t)) throw new StetError(`"${ticket}" doesn't look like a ticket ID (expected something like LIN-142).`);
|
|
414
|
+
const cleanTask = t ? task.replace(new RegExp(`^\\s*${t}\\s*[:\\-–—]?\\s*`), '').trim() || task : task;
|
|
415
|
+
const decided = decisionsList(r);
|
|
416
|
+
r.wjson('scope.json', { ticket: t, task: cleanTask, allow: a, base: r.head(true) || null, set: new Date().toISOString(), decisionsBefore: decided.length });
|
|
417
|
+
sync(r);
|
|
418
|
+
return out(`${OK()} Scope set: ${t ? c.violet(t) + ' ' : ''}${c.bold(cleanTask)}\n allowed: ${a.map((g) => c.cyan(g)).join(', ')}\n base: ${r.head() || '(no commits)'} — guard compares everything changed since here${t ? `\n ticket: ${t} is added to commit messages (stet hook install) and PR text (stet pr)` : ''}`);
|
|
362
419
|
}
|
|
363
420
|
function scopeShow(r) {
|
|
364
421
|
r.need(); const s = r.scope;
|
|
365
422
|
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'}`)}`);
|
|
423
|
+
return out(`${s.ticket ? `${c.bold('Ticket:')} ${s.ticket}\n` : ''}${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
424
|
}
|
|
425
|
+
// Prints only the ticket ID (or nothing). Used by the prepare-commit-msg hook.
|
|
426
|
+
function scopeTicket(r) { r.need(); return out(r.scope.ticket || ''); }
|
|
368
427
|
function scopeClear(r) { r.need(); r.wjson('scope.json', {}); sync(r); return out(`${OK()} Scope cleared.`); }
|
|
369
428
|
|
|
370
429
|
function guard(r, { base, staged, warn } = {}) {
|
|
@@ -380,7 +439,7 @@ function guard(r, { base, staged, warn } = {}) {
|
|
|
380
439
|
}
|
|
381
440
|
const lines = [];
|
|
382
441
|
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}`) : ''));
|
|
442
|
+
lines.push(c.bold(`Guarding ${files.length} changed file${files.length === 1 ? '' : 's'}`) + c.dim(` (${what})`) + (s.task ? c.dim(` · ${s.ticket ? s.ticket + ' ' : 'task: '}${s.task}`) : ''));
|
|
384
443
|
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
444
|
for (const f of files) {
|
|
386
445
|
const bad = outScope.includes(f) || protectedHits.includes(f);
|
|
@@ -495,7 +554,7 @@ Generated by Stet ${VERSION} on ${today()}. Regenerate any time with \`stet hand
|
|
|
495
554
|
${charter}
|
|
496
555
|
|
|
497
556
|
## 2. Run it
|
|
498
|
-
${st.run.length || st.
|
|
557
|
+
${st.run.length || st.tests.length ? [...st.run, ...st.tests.map((t) => t + ' # tests')].map((x) => '- `' + x + '`').join('\n') : '- No run scripts detected. Add them to the charter.'}
|
|
499
558
|
${envKeys.length ? `\nEnvironment variables needed (names only, from the example env file):\n${envKeys.map((k) => '`' + k + '`').join(' · ')}` : ''}
|
|
500
559
|
|
|
501
560
|
## 3. Architecture at a glance
|
|
@@ -532,6 +591,96 @@ ${g ? `\nRecent commits:\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate',
|
|
|
532
591
|
return out(md);
|
|
533
592
|
}
|
|
534
593
|
|
|
594
|
+
// ---------- PR / commit text ----------
|
|
595
|
+
// Builds a commit message and PR description from facts only: the diff since the scope was set,
|
|
596
|
+
// the scope and its ticket, guard results, the last stet check, and decisions logged during the task.
|
|
597
|
+
// The one thing Stet can't know — the "why" in plain words — is left as a marked line for you or your AI.
|
|
598
|
+
function pr(r, { base, runCheck } = {}) {
|
|
599
|
+
r.need();
|
|
600
|
+
if (!r.isGit() || !r.head()) throw new StetError('stet pr needs a git repo with at least one commit.');
|
|
601
|
+
const s = r.scope;
|
|
602
|
+
const want = base || s.base || 'HEAD';
|
|
603
|
+
const b = r.tgit(['rev-parse', '--verify', '--quiet', want + '^{commit}']).trim() ? want : 'HEAD';
|
|
604
|
+
|
|
605
|
+
// What changed (committed + staged + unstaged since base, plus new untracked files).
|
|
606
|
+
const status = {}, lines = {};
|
|
607
|
+
for (const l of nonEmpty(r.tgit(['diff', '--name-status', '-M', b]))) {
|
|
608
|
+
const [code, ...paths] = l.split('\t');
|
|
609
|
+
const f = paths[paths.length - 1];
|
|
610
|
+
status[f] = code[0] === 'A' ? 'added' : code[0] === 'D' ? 'deleted' : code[0] === 'R' ? `renamed from \`${paths[0]}\`` : 'modified';
|
|
611
|
+
}
|
|
612
|
+
for (const l of nonEmpty(r.tgit(['diff', '--numstat', '-M', b]))) {
|
|
613
|
+
const [add, del, ...rest] = l.split('\t');
|
|
614
|
+
// Renames show as "old => new" or "dir/{a => b}/file"; keep the new path.
|
|
615
|
+
const f = rest.join('\t').replace(/\{[^}]* => ([^}]*)\}/, '$1').replace(/^.* => /, '').replace(/\/\//g, '/');
|
|
616
|
+
lines[f] = add === '-' ? null : [+add, +del];
|
|
617
|
+
}
|
|
618
|
+
for (const f of names(r.tgit(['ls-files', '--others', '--exclude-standard']))) {
|
|
619
|
+
status[f] = 'added';
|
|
620
|
+
try { const t = fs.readFileSync(path.join(r.root, f)); lines[f] = t.includes(0) ? null : [t.toString('utf8').split('\n').length - (t.toString('utf8').endsWith('\n') ? 1 : 0), 0]; } catch { lines[f] = null; }
|
|
621
|
+
}
|
|
622
|
+
const files = Object.keys(status).filter((f) => !matches(f, STET_OWNED)).sort();
|
|
623
|
+
const plus = files.reduce((n, f) => n + ((lines[f] || [0])[0] || 0), 0);
|
|
624
|
+
const minus = files.reduce((n, f) => n + ((lines[f] || [0, 0])[1] || 0), 0);
|
|
625
|
+
|
|
626
|
+
// Scope and locks.
|
|
627
|
+
const g = guard(r, { base: b, warn: true }).data;
|
|
628
|
+
let checkRes = null, checkNote = '';
|
|
629
|
+
if (runCheck) { checkRes = check(r, {}).data.results; checkNote = `ran just now at commit \`${r.head()}\``; }
|
|
630
|
+
else {
|
|
631
|
+
const last = r.json('.cache/last-check.json', null);
|
|
632
|
+
if (last) {
|
|
633
|
+
checkRes = last.results;
|
|
634
|
+
checkNote = `last run ${last.at.slice(0, 16).replace('T', ' ')} UTC at commit \`${last.head}\`${last.head !== r.head() ? ' — older than the latest commit, rerun `stet check`' : ''}`;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const newDecisions = s.task ? decisionsList(r).slice(s.decisionsBefore || 0) : [];
|
|
638
|
+
const decMd = r.read('decisions.md');
|
|
639
|
+
const why = (id) => { const m = decMd.match(new RegExp(`^## ${id} · .*\\n([^#][\\s\\S]*?)(?=\\n## |$)`, 'm')); return m ? m[1].trim().split('\n')[0] : ''; };
|
|
640
|
+
|
|
641
|
+
const title = s.task ? `${s.ticket ? s.ticket + ': ' : ''}${s.task}` : 'Describe this change';
|
|
642
|
+
const MAX = 30;
|
|
643
|
+
|
|
644
|
+
// Commit message: title, blank line, one bullet per file.
|
|
645
|
+
const commit = [title, '', ...files.slice(0, 15).map((f) => `- ${status[f].startsWith('renamed') ? 'rename' : status[f] === 'added' ? 'add' : status[f] === 'deleted' ? 'remove' : 'update'} ${f}`), ...(files.length > 15 ? [`- and ${files.length - 15} more files`] : [])].join('\n') + '\n';
|
|
646
|
+
|
|
647
|
+
const md = [];
|
|
648
|
+
md.push(`## ${title}`, '');
|
|
649
|
+
if (s.ticket) md.push(`**Ticket:** ${s.ticket}`, '');
|
|
650
|
+
md.push('### What and why', '<!-- Replace this line with 1–2 sentences: what changes for the user, and why. -->', s.task ? `${s.task}.` : '', '');
|
|
651
|
+
md.push(`### Changes · ${files.length} file${files.length === 1 ? '' : 's'}, +${plus} −${minus}`);
|
|
652
|
+
if (files.length) {
|
|
653
|
+
md.push('| File | Change | Lines |', '|---|---|---|');
|
|
654
|
+
for (const f of files.slice(0, MAX)) md.push(`| \`${f}\` | ${status[f]} | ${lines[f] ? `+${lines[f][0]} −${lines[f][1]}` : 'binary'} |`);
|
|
655
|
+
if (files.length > MAX) md.push(`| … | ${files.length - MAX} more files | |`);
|
|
656
|
+
} else md.push('No changes since the base commit yet.');
|
|
657
|
+
md.push('');
|
|
658
|
+
md.push('### Scope');
|
|
659
|
+
if (!s.task) md.push('⚠️ No scope was set for this change.');
|
|
660
|
+
else if (g.violations.length) md.push(`⚠️ ${g.violations.length} file${g.violations.length > 1 ? 's are' : ' is'} outside the agreed scope or in protected paths:`, ...g.violations.map((f) => `- \`${f}\``), `Agreed scope: ${s.allow.map((x) => '`' + x + '`').join(', ')}`);
|
|
661
|
+
else md.push(`✅ All changes are inside the agreed scope: ${s.allow.map((x) => '`' + x + '`').join(', ')}`);
|
|
662
|
+
md.push('');
|
|
663
|
+
md.push('### Locked behaviours');
|
|
664
|
+
if (!checkRes || !checkRes.length) md.push(r.locks.length ? '⚠️ `stet check` has not been run for this change yet.' : 'No locked behaviours in this repo yet.');
|
|
665
|
+
else {
|
|
666
|
+
for (const x of checkRes) md.push(`- ${x.ok ? '✅' : x.cantRun ? '⚠️' : '❌'} **${x.id}** ${x.name}${x.ok ? '' : x.cantRun ? ' — proof command could not run' : ' — failing'}`);
|
|
667
|
+
md.push('', `<sub>stet check ${checkNote}</sub>`);
|
|
668
|
+
}
|
|
669
|
+
if (g.lockIds.length) md.push('', `Touches files watched by ${g.lockIds.join(', ')}.`);
|
|
670
|
+
md.push('');
|
|
671
|
+
if (newDecisions.length) {
|
|
672
|
+
md.push('### Decisions made in this change');
|
|
673
|
+
for (const d of newDecisions) { const w = why(d.id); md.push(`- **${d.id}** ${d.title}${w ? ` — ${w}` : ''}`); }
|
|
674
|
+
md.push('');
|
|
675
|
+
}
|
|
676
|
+
md.push('---', '<sub>Generated by [Stet](https://github.com/shamazkhan/stet) from the git diff, the task scope and check results.</sub>', '');
|
|
677
|
+
|
|
678
|
+
const prText = md.join('\n');
|
|
679
|
+
r.write('.cache/pr.md', prText);
|
|
680
|
+
r.write('.cache/commit.txt', commit);
|
|
681
|
+
return out(prText, 0, { title, commit, pr: prText, files: files.length, violations: g.violations, checks: checkRes });
|
|
682
|
+
}
|
|
683
|
+
|
|
535
684
|
// ---------- status ----------
|
|
536
685
|
function status(r) {
|
|
537
686
|
r.need();
|
|
@@ -548,24 +697,41 @@ function status(r) {
|
|
|
548
697
|
}
|
|
549
698
|
|
|
550
699
|
// ---------- git hook ----------
|
|
700
|
+
const HOOK_BEG = '# >>> stet >>>', HOOK_END = '# <<< stet <<<';
|
|
701
|
+
const STET_BIN = 'if command -v stet >/dev/null 2>&1; then STET="stet"; else STET="npx --no-install stet-cli"; fi';
|
|
702
|
+
function writeHook(hp, block) {
|
|
703
|
+
let cur = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : '';
|
|
704
|
+
if (cur.includes(HOOK_BEG)) cur = cur.slice(0, cur.indexOf(HOOK_BEG)) + cur.slice(cur.indexOf(HOOK_END) + HOOK_END.length + 1);
|
|
705
|
+
if (block == null) { if (fs.existsSync(hp)) fs.writeFileSync(hp, cur); return; }
|
|
706
|
+
const next = (cur.trim() ? cur.replace(/\s*$/, '\n') : '#!/bin/sh\n') + block;
|
|
707
|
+
fs.mkdirSync(path.dirname(hp), { recursive: true });
|
|
708
|
+
fs.writeFileSync(hp, next); fs.chmodSync(hp, 0o755);
|
|
709
|
+
}
|
|
551
710
|
function hook(r, { uninstall, withCheck } = {}) {
|
|
552
711
|
r.need();
|
|
553
712
|
const gitDir = r.tgit(['rev-parse', '--git-path', 'hooks']).trim();
|
|
554
713
|
if (!gitDir) throw new StetError('Not a git repo.');
|
|
555
|
-
const
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if command -v stet >/dev/null 2>&1; then STET="stet"; else STET="npx --no-install stet"; fi
|
|
714
|
+
const pre = path.resolve(r.root, gitDir, 'pre-commit');
|
|
715
|
+
const msg = path.resolve(r.root, gitDir, 'prepare-commit-msg');
|
|
716
|
+
if (uninstall) { writeHook(pre, null); writeHook(msg, null); return out(`${OK()} Removed Stet from the git hooks.`); }
|
|
717
|
+
// pre-commit: block commits that stage out-of-scope or protected files.
|
|
718
|
+
writeHook(pre, `${HOOK_BEG}
|
|
719
|
+
${STET_BIN}
|
|
562
720
|
$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'}${
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
721
|
+
${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'}${HOOK_END}
|
|
722
|
+
`);
|
|
723
|
+
// prepare-commit-msg: prefix the ticket ID ("LIN-142: ...") unless the message already has it.
|
|
724
|
+
// $2 is empty (editor) or "message" (-m); merges, squashes and amends are left alone.
|
|
725
|
+
writeHook(msg, `${HOOK_BEG}
|
|
726
|
+
case "$2" in ""|message|template)
|
|
727
|
+
${STET_BIN}
|
|
728
|
+
T=$($STET scope ticket --no-color 2>/dev/null)
|
|
729
|
+
if [ -n "$T" ] && ! grep -q "$T" "$1"; then { printf '%s: ' "$T"; cat "$1"; } > "$1.stet" && mv "$1.stet" "$1"; fi
|
|
730
|
+
;;
|
|
731
|
+
esac
|
|
732
|
+
${HOOK_END}
|
|
733
|
+
`);
|
|
734
|
+
return out(`${OK()} Git hooks installed:\n pre-commit blocks commits that stage out-of-scope or protected files${withCheck ? ' and runs stet check' : ' (STET_CHECK=1 also runs stet check)'}\n prepare-commit-msg adds the scope's ticket ID to commit messages, e.g. "LIN-142: …"`);
|
|
569
735
|
}
|
|
570
736
|
|
|
571
737
|
// ---------- MCP config ----------
|
|
@@ -577,4 +743,4 @@ function mcpInstall(r) {
|
|
|
577
743
|
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
744
|
}
|
|
579
745
|
|
|
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 };
|
|
746
|
+
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, pr, scopeTicket, globRe, matches, detect, listFiles, changedFiles };
|
package/lib/mcp.js
CHANGED
|
@@ -8,6 +8,8 @@ const TOOLS = [
|
|
|
8
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
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
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_pr', description: 'Draft the commit message and pull request description for the current task from facts: the git diff since the scope was set, the ticket ID, scope results, the last stet check and decisions logged. Fill in the "What and why" line yourself. Use runCheck to run the locked behaviours first.',
|
|
12
|
+
inputSchema: { type: 'object', properties: { base: { type: 'string', description: 'Branch or commit to compare against, e.g. "main". Default: where the scope was set.' }, runCheck: { type: 'boolean', description: 'Run stet check first' } } } },
|
|
11
13
|
{ 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
14
|
];
|
|
13
15
|
|
|
@@ -18,6 +20,7 @@ function callTool(name, a = {}) {
|
|
|
18
20
|
case 'stet_guard': return core.guard(r, {});
|
|
19
21
|
case 'stet_check': return core.check(r, { only: a.only });
|
|
20
22
|
case 'stet_scope': return core.scopeShow(r);
|
|
23
|
+
case 'stet_pr': { const p = core.pr(r, { base: a.base, runCheck: a.runCheck }); return { text: `COMMIT MESSAGE\n\n${p.data.commit}\nPR DESCRIPTION\n\n${p.data.pr}`, code: 0 }; }
|
|
21
24
|
case 'stet_decide': return core.decide(r, a.decision, { why: a.why });
|
|
22
25
|
default: throw new core.StetError('Unknown tool: ' + name);
|
|
23
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"bin": {
|
|
6
6
|
"stet": "bin/stet.js"
|