stet-cli 0.1.3 → 0.3.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-11-14
4
+ - Linear integration (optional, needs `LINEAR_API_KEY`): `stet ticket LIN-142` fetches a ticket; `stet scope set LIN-142 --allow …` uses the Linear title as the task.
5
+ - The ticket (title, status, priority, labels, link, description) is included at the top of `stet context`, so one paste gives your AI the ticket and the code.
6
+ - `stet pr` links the ticket ID to Linear.
7
+ - Privacy: only the ticket ID is sent to Linear; tickets are cached in `.stet/.cache/` (gitignored).
8
+
9
+ ## 0.2.0 — 2026-11-14
10
+ - 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.
11
+ - 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.
12
+ - `stet hook install` now also adds a prepare-commit-msg hook that prefixes commit messages with the ticket ID ("LIN-142: …"), never twice.
13
+ - New MCP tool `stet_pr` so Claude Code and Cursor can draft the PR text.
14
+ - The pre-commit hook falls back to `npx stet-cli` when stet isn't installed globally.
15
+
3
16
  ## 0.1.3 — 2026-11-14
4
17
  - 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.
5
18
  - stet init creates one test lock per sub-project (e.g. `cd backend && pytest -q`).
package/README.md CHANGED
@@ -9,10 +9,13 @@ 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
+ - **Linear tickets.** `stet scope set LIN-142 --allow "…"` pulls the ticket's title and description from Linear into the context pack, so your AI gets the ticket and the code in one paste.
13
+ - **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
14
  - **Handoff pack.** `stet handoff` writes an onboarding document for a new developer or CTO, built from the repo.
13
15
  - **MCP server.** `stet mcp` exposes all of this as tools to Claude Code, Cursor and any MCP client.
14
16
 
15
17
  No account. No telemetry. Your code never leaves your machine. Zero dependencies. Node 18+.
18
+ The only network call is optional: if you set `LINEAR_API_KEY`, `stet ticket` fetches the Linear ticket you name (only the ticket ID is sent).
16
19
 
17
20
  ## Install
18
21
 
@@ -43,13 +46,17 @@ Commit `.stet/`, `AGENTS.md` and `CLAUDE.md`. Every agent and teammate then work
43
46
  # once: lock what already works
44
47
  stet lock add "UAE VAT maths is correct" --cmd "node --test test/vat.test.js" --protect "src/vat/**"
45
48
 
46
- # before each AI task
47
- stet scope set "Add CSV export of invoices" --allow "src/export/**,test/export*"
49
+ # before each AI task (the ticket ID is optional)
50
+ stet scope set "LIN-142 Add CSV export of invoices" --allow "src/export/**,test/export*"
48
51
  stet context --files "src/invoices/**" # copied to clipboard; paste into your chat
49
52
 
50
53
  # after the AI says it's done
51
54
  stet guard # anything outside scope or in protected paths?
52
55
  stet check # do all locked behaviours still pass?
56
+
57
+ # ship it
58
+ stet pr # commit message + PR description (PR copied to clipboard)
59
+ git commit -F .stet/.cache/commit.txt # "LIN-142: Add CSV export of invoices" + file list
53
60
  ```
54
61
 
55
62
  Example (real output from a sample accounting app):
@@ -81,8 +88,8 @@ Checking 2 locked behaviours
81
88
  |---|---|
82
89
  | `stet init [--name X] [--mcp]` | Set up `.stet/`, `AGENTS.md` and `CLAUDE.md`. `--mcp` also writes `.mcp.json`. Safe to re-run. |
83
90
  | `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. |
91
+ | `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. |
92
+ | `stet scope [show\|clear\|ticket]` | Show or clear the scope, or print just the ticket ID. |
86
93
  | `stet guard [--staged] [--base ref] [--warn]` | Check changes since the scope base (committed, staged, unstaged and untracked). Exits 1 on violations unless `--warn`. |
87
94
  | `stet lock add "<behaviour>" --cmd "<cmd>" [--protect "<globs>"]` | Lock a behaviour. `--protect` tells guard which files affect it. |
88
95
  | `stet lock [list\|rm L2]` | List locks with their last result, or remove one. |
@@ -90,13 +97,35 @@ Checking 2 locked behaviours
90
97
  | `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
98
  | `stet decide "<decision>" [--why "<reason>"]` | Add to the decision log. |
92
99
  | `stet decisions [--all]` | List decisions. |
100
+ | `stet ticket [LIN-142] [--stdout] [--cached]` | Fetch a Linear ticket (needs `LINEAR_API_KEY`). It is included in `stet context` and linked in `stet pr`. With no ID, shows the current scope's ticket. |
101
+ | `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
102
  | `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`. |
103
+ | `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
104
  | `stet status` | One-screen summary. |
96
105
  | `stet mcp` / `stet mcp install` | Run the MCP server over stdio, or write `.mcp.json`. |
97
106
 
98
107
  Add `--json` to any command for machine-readable output.
99
108
 
109
+ ## Linear
110
+
111
+ 1. In Linear: **Settings → Security & access → Personal API keys → New key** (read access is enough).
112
+ 2. Put it in your shell, never in the repo:
113
+
114
+ ```bash
115
+ echo 'export LINEAR_API_KEY=lin_api_...' >> ~/.zshrc && source ~/.zshrc
116
+ ```
117
+
118
+ 3. Work from tickets:
119
+
120
+ ```bash
121
+ stet scope set LIN-142 --allow "src/export/**,tests/export/**" # task = the Linear title
122
+ stet context --files "src/export/**" # ticket + rules + code, ready to paste into ChatGPT
123
+ stet ticket # show the current ticket again (stet ticket LIN-150 for another)
124
+ stet pr # PR text links back to the Linear ticket
125
+ ```
126
+
127
+ Tickets are cached in `.stet/.cache/` (gitignored). Only the ticket ID is sent to Linear; nothing about your code is.
128
+
100
129
  ## Use with your AI tools
101
130
 
102
131
  **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`.
@@ -111,7 +140,7 @@ Add `--json` to any command for machine-readable output.
111
140
 
112
141
  **ChatGPT, Claude.ai, Gemini (chat):** run `stet context --files "<what you're working on>"` and paste the result.
113
142
 
114
- MCP tools: `stet_context`, `stet_guard`, `stet_check`, `stet_scope` (read-only; only the human sets scope) and `stet_decide`.
143
+ MCP tools: `stet_context`, `stet_guard`, `stet_check`, `stet_scope` (read-only; only the human sets scope), `stet_pr` and `stet_decide`.
115
144
 
116
145
  ## Configuration
117
146
 
@@ -138,7 +167,7 @@ Globs support `*`, `**` and `?`. A bare folder name like `migrations` matches ev
138
167
 
139
168
  ## FAQ
140
169
 
141
- **Does Stet send my code anywhere?** No. It's a local CLI with no network calls and no telemetry.
170
+ **Does Stet send my code anywhere?** No. It's a local CLI with no telemetry. Its only network call is the optional Linear fetch, which sends just the ticket ID you ask for.
142
171
 
143
172
  **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
173
 
package/bin/stet.js CHANGED
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  const core = require('../lib/core');
6
6
  const { c } = core;
7
7
 
8
- const BOOL = new Set(['help', 'h', 'version', 'v', 'staged', 'warn', 'diff', 'changed', 'stdout', 'copy', 'all', 'mcp', 'uninstall', 'check', 'json', 'no-color']);
8
+ const BOOL = new Set(['help', 'h', 'version', 'v', 'staged', 'warn', 'diff', 'changed', 'stdout', 'copy', 'all', 'mcp', 'uninstall', 'check', 'json', 'no-color', 'cached']);
9
9
  function parse(argv) {
10
10
  const pos = [], fl = {};
11
11
  for (let i = 0; i < argv.length; i++) {
@@ -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
- stet scope [show|clear]
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,15 @@ ${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('Linear')} (optional · needs LINEAR_API_KEY in your shell)
42
+ stet ticket LIN-142 [--stdout|--json] Fetch a ticket; it goes into stet context and stet pr
43
+ stet scope set LIN-142 --allow "<globs>" Uses the Linear title as the task
44
+
45
+ ${c.bold('Shipping')}
46
+ stet pr [--base main] [--check] [--stdout]
47
+ Commit message + PR description from the diff, scope, checks and decisions
48
+ Then: git commit -F .stet/.cache/commit.txt
49
+
40
50
  ${c.bold('Memory')}
41
51
  stet lock add "<behaviour>" --cmd "<proof command>" [--protect "glob,glob"]
42
52
  stet lock [list|rm L2]
@@ -52,7 +62,7 @@ ${c.bold('Team')}
52
62
 
53
63
  Docs: https://stet-8fe08a58.base44.app`;
54
64
 
55
- function main() {
65
+ async function main() {
56
66
  const { pos, fl } = parse(process.argv.slice(2));
57
67
  if (fl['no-color']) core.setColor(false);
58
68
  const [cmd, sub, ...rest] = pos;
@@ -77,9 +87,26 @@ function main() {
77
87
  case 'check': res = core.check(r, { only: fl.only }); break;
78
88
  case 'scope':
79
89
  if (!sub || sub === 'show') res = core.scopeShow(r);
80
- else if (sub === 'set') res = core.scopeSet(r, rest.join(' '), { allow: fl.allow });
90
+ else if (sub === 'set') {
91
+ let task = rest.join(' ');
92
+ const id = (typeof fl.ticket === 'string' ? fl.ticket : (task.match(/\b[A-Z][A-Z0-9]{1,9}-\d+\b/) || [])[0] || '').toUpperCase();
93
+ const notes = [];
94
+ // With LINEAR_API_KEY set, fetch the ticket so its title and description reach the context pack.
95
+ if (id && process.env.LINEAR_API_KEY) {
96
+ try {
97
+ const linear = require('../lib/linear');
98
+ const issue = await linear.fetchIssue(id);
99
+ linear.saveIssue(r, issue);
100
+ if (task.trim().toUpperCase() === id) task = `${id} ${issue.title}`; // "stet scope set LIN-142" → use the Linear title
101
+ notes.push(`${c.green('✓')} Linear: ${issue.title}${issue.state ? c.dim(' · ' + issue.state.name) : ''}`);
102
+ } catch (e) { notes.push(c.yellow(`! Linear: ${e.message.split('\n')[0]} (scope set anyway)`)); }
103
+ }
104
+ res = core.scopeSet(r, task, { allow: fl.allow, ticket: fl.ticket });
105
+ if (notes.length) res.text += '\n' + notes.map((n) => ' ' + n).join('\n');
106
+ }
107
+ else if (sub === 'ticket') res = core.scopeTicket(r);
81
108
  else if (sub === 'clear') res = core.scopeClear(r);
82
- else throw new core.StetError('Usage: stet scope [show|set|clear]');
109
+ else throw new core.StetError('Usage: stet scope [show|set|clear|ticket]');
83
110
  break;
84
111
  case 'guard': res = core.guard(r, { staged: fl.staged, base: fl.base, warn: fl.warn }); break;
85
112
  case 'context': case 'ctx': {
@@ -102,6 +129,44 @@ function main() {
102
129
  res = { code: 0, text: `${c.green('✓')} Handoff pack written to ${c.bold(path.relative(process.cwd(), file) || file)}` };
103
130
  break;
104
131
  }
132
+ case 'pr': {
133
+ core.setColor(false);
134
+ const p = core.pr(r, { base: fl.base, runCheck: fl.check });
135
+ core.setColor((process.stdout.isTTY || process.env.STET_COLOR === '1') && !process.env.NO_COLOR && !fl['no-color']);
136
+ if (fl.stdout) { process.stdout.write(p.text); return; }
137
+ const copied = core.copyToClipboard(p.data.pr);
138
+ const warn = [];
139
+ if (p.data.violations.length) warn.push(c.yellow(`! ${p.data.violations.length} file(s) outside scope or protected — listed in the PR text.`));
140
+ if (!p.data.checks) warn.push(c.yellow('! stet check has not been run for this change. Run it, or use stet pr --check.'));
141
+ else if (p.data.checks.some((x) => !x.ok)) warn.push(c.red('✗ A locked behaviour is failing — listed in the PR text.'));
142
+ res = { code: 0, text: [
143
+ `${c.green('✓')} ${c.bold(p.data.title)} ${c.dim(`(${p.data.files} files)`)}`,
144
+ ` commit message .stet/.cache/commit.txt → ${c.cyan('git commit -F .stet/.cache/commit.txt')}`,
145
+ ` PR description .stet/.cache/pr.md → ${copied ? 'copied to clipboard' : 'open the file, or run: stet pr --stdout'}`,
146
+ ...warn.map((w) => ' ' + w),
147
+ c.dim(' Fill in the "What and why" line (or paste the PR text into your AI and ask it to write it).'),
148
+ ].join('\n') };
149
+ break;
150
+ }
151
+ case 'ticket': {
152
+ const linear = require('../lib/linear');
153
+ r.need();
154
+ const id = (sub || r.scope.ticket || '').toUpperCase();
155
+ if (!id) throw new core.StetError('Usage: stet ticket LIN-142 (or set a scope with a ticket ID first)');
156
+ let issue = fl.cached ? linear.cachedIssue(r, id) : null;
157
+ if (!issue) { issue = await linear.fetchIssue(id); linear.saveIssue(r, issue); }
158
+ if (fl.stdout) { process.stdout.write(linear.issueMd(issue) + '\n'); return; }
159
+ const body = linear.issueMd(issue).split('\n');
160
+ res = { code: 0, text: [
161
+ `${c.green('✓')} ${c.violet(issue.identifier)} ${c.bold(issue.title)}`,
162
+ ...body.slice(1, 3).filter(Boolean).map((l) => ' ' + c.dim(l)),
163
+ '', ...body.slice(3).join('\n').trim().split('\n').slice(0, 12).map((l) => ' ' + l),
164
+ ].join('\n'), data: issue };
165
+ res.text += '\n' + [
166
+ '', c.dim(` Saved for this repo. ${r.scope.ticket === issue.identifier ? 'It is included in stet context and stet pr.' : `Start work on it: stet scope set ${issue.identifier} --allow "<globs>"`}`),
167
+ ].join('\n');
168
+ break;
169
+ }
105
170
  case 'status': case 'st': res = core.status(r); break;
106
171
  case 'hook':
107
172
  if (sub === 'install') res = core.hook(r, { withCheck: fl.check });
@@ -118,7 +183,7 @@ function main() {
118
183
  process.exitCode = res.code;
119
184
  }
120
185
 
121
- try { main(); } catch (e) {
186
+ main().catch((e) => {
122
187
  if (e instanceof core.StetError) { console.error(c.red('stet: ') + e.message); process.exitCode = e.code || 1; }
123
188
  else { console.error(e); process.exitCode = 2; }
124
- }
189
+ });
package/lib/core.js CHANGED
@@ -233,7 +233,7 @@ function decisionsList(r) {
233
233
  function scopeBlock(r) {
234
234
  const s = r.scope;
235
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`.';
236
- 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.`;
237
237
  }
238
238
  function locksBlock(r) {
239
239
  const L = r.locks;
@@ -402,18 +402,28 @@ function check(r, { only } = {}) {
402
402
  }
403
403
 
404
404
  // ---------- scope + guard ----------
405
- function scopeSet(r, task, { allow } = {}) {
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 } = {}) {
406
408
  r.need();
407
409
  const a = splitList(allow);
408
- if (!task || !a.length) throw new StetError('Usage: stet scope set "<task>" --allow "src/feature/**,tests/feature/**"');
409
- r.wjson('scope.json', { task, allow: a, base: r.head(true) || null, set: new Date().toISOString() }); sync(r);
410
- 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`);
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)` : ''}`);
411
419
  }
412
420
  function scopeShow(r) {
413
421
  r.need(); const s = r.scope;
414
422
  if (!s.task) return out('No scope set. Set one before an AI task: stet scope set "<task>" --allow "<globs>"');
415
- 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'}`)}`);
416
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 || ''); }
417
427
  function scopeClear(r) { r.need(); r.wjson('scope.json', {}); sync(r); return out(`${OK()} Scope cleared.`); }
418
428
 
419
429
  function guard(r, { base, staged, warn } = {}) {
@@ -429,7 +439,7 @@ function guard(r, { base, staged, warn } = {}) {
429
439
  }
430
440
  const lines = [];
431
441
  const what = staged ? 'staged files' : `changes since ${(base || s.base || 'HEAD').slice(0, 7)}`;
432
- 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}`) : ''));
433
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>"`);
434
444
  for (const f of files) {
435
445
  const bad = outScope.includes(f) || protectedHits.includes(f);
@@ -481,6 +491,9 @@ function context(r, { files, changed, diff, maxKB } = {}) {
481
491
  parts.push(`# Context pack — ${cfg.name}`);
482
492
  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`);
483
493
  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.`);
494
+ // The Linear ticket (if fetched with `stet ticket` / `stet scope set LIN-142`) goes first: it is the task.
495
+ const tk = r.scope.ticket && require('./linear').cachedIssue(r, r.scope.ticket);
496
+ if (tk) parts.push(require('./linear').issueMd(tk));
484
497
  parts.push(agentsBlock(r).replace(BEGIN, '').replace(END, '').replace(/^<!--.*-->$/m, '').replace(/^### /gm, '#### ').replace(/^# Working agreement.*$/m, '## Working agreement').replace(/^## (?!Working)/gm, '### ').trim());
485
498
  if (g && r.head()) parts.push(`## Recent commits\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate', '-15']).trim()}\n\`\`\``);
486
499
  if (status.length) parts.push(`## Uncommitted changes\n\`\`\`\n${status.join('\n')}\n\`\`\``);
@@ -581,6 +594,97 @@ ${g ? `\nRecent commits:\n\`\`\`\n${r.tgit(['log', '--oneline', '--no-decorate',
581
594
  return out(md);
582
595
  }
583
596
 
597
+ // ---------- PR / commit text ----------
598
+ // Builds a commit message and PR description from facts only: the diff since the scope was set,
599
+ // the scope and its ticket, guard results, the last stet check, and decisions logged during the task.
600
+ // The one thing Stet can't know — the "why" in plain words — is left as a marked line for you or your AI.
601
+ function pr(r, { base, runCheck } = {}) {
602
+ r.need();
603
+ if (!r.isGit() || !r.head()) throw new StetError('stet pr needs a git repo with at least one commit.');
604
+ const s = r.scope;
605
+ const want = base || s.base || 'HEAD';
606
+ const b = r.tgit(['rev-parse', '--verify', '--quiet', want + '^{commit}']).trim() ? want : 'HEAD';
607
+
608
+ // What changed (committed + staged + unstaged since base, plus new untracked files).
609
+ const status = {}, lines = {};
610
+ for (const l of nonEmpty(r.tgit(['diff', '--name-status', '-M', b]))) {
611
+ const [code, ...paths] = l.split('\t');
612
+ const f = paths[paths.length - 1];
613
+ status[f] = code[0] === 'A' ? 'added' : code[0] === 'D' ? 'deleted' : code[0] === 'R' ? `renamed from \`${paths[0]}\`` : 'modified';
614
+ }
615
+ for (const l of nonEmpty(r.tgit(['diff', '--numstat', '-M', b]))) {
616
+ const [add, del, ...rest] = l.split('\t');
617
+ // Renames show as "old => new" or "dir/{a => b}/file"; keep the new path.
618
+ const f = rest.join('\t').replace(/\{[^}]* => ([^}]*)\}/, '$1').replace(/^.* => /, '').replace(/\/\//g, '/');
619
+ lines[f] = add === '-' ? null : [+add, +del];
620
+ }
621
+ for (const f of names(r.tgit(['ls-files', '--others', '--exclude-standard']))) {
622
+ status[f] = 'added';
623
+ 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; }
624
+ }
625
+ const files = Object.keys(status).filter((f) => !matches(f, STET_OWNED)).sort();
626
+ const plus = files.reduce((n, f) => n + ((lines[f] || [0])[0] || 0), 0);
627
+ const minus = files.reduce((n, f) => n + ((lines[f] || [0, 0])[1] || 0), 0);
628
+
629
+ // Scope and locks.
630
+ const g = guard(r, { base: b, warn: true }).data;
631
+ let checkRes = null, checkNote = '';
632
+ if (runCheck) { checkRes = check(r, {}).data.results; checkNote = `ran just now at commit \`${r.head()}\``; }
633
+ else {
634
+ const last = r.json('.cache/last-check.json', null);
635
+ if (last) {
636
+ checkRes = last.results;
637
+ 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`' : ''}`;
638
+ }
639
+ }
640
+ const newDecisions = s.task ? decisionsList(r).slice(s.decisionsBefore || 0) : [];
641
+ const decMd = r.read('decisions.md');
642
+ const why = (id) => { const m = decMd.match(new RegExp(`^## ${id} · .*\\n([^#][\\s\\S]*?)(?=\\n## |$)`, 'm')); return m ? m[1].trim().split('\n')[0] : ''; };
643
+
644
+ const title = s.task ? `${s.ticket ? s.ticket + ': ' : ''}${s.task}` : 'Describe this change';
645
+ const MAX = 30;
646
+
647
+ // Commit message: title, blank line, one bullet per file.
648
+ 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';
649
+
650
+ const md = [];
651
+ md.push(`## ${title}`, '');
652
+ const tk = s.ticket && require('./linear').cachedIssue(r, s.ticket);
653
+ if (s.ticket) md.push(`**Ticket:** ${tk && tk.url ? `[${s.ticket}](${tk.url})` : s.ticket}`, '');
654
+ md.push('### What and why', '<!-- Replace this line with 1–2 sentences: what changes for the user, and why. -->', s.task ? `${s.task}.` : '', '');
655
+ md.push(`### Changes · ${files.length} file${files.length === 1 ? '' : 's'}, +${plus} −${minus}`);
656
+ if (files.length) {
657
+ md.push('| File | Change | Lines |', '|---|---|---|');
658
+ for (const f of files.slice(0, MAX)) md.push(`| \`${f}\` | ${status[f]} | ${lines[f] ? `+${lines[f][0]} −${lines[f][1]}` : 'binary'} |`);
659
+ if (files.length > MAX) md.push(`| … | ${files.length - MAX} more files | |`);
660
+ } else md.push('No changes since the base commit yet.');
661
+ md.push('');
662
+ md.push('### Scope');
663
+ if (!s.task) md.push('⚠️ No scope was set for this change.');
664
+ 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(', ')}`);
665
+ else md.push(`✅ All changes are inside the agreed scope: ${s.allow.map((x) => '`' + x + '`').join(', ')}`);
666
+ md.push('');
667
+ md.push('### Locked behaviours');
668
+ 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.');
669
+ else {
670
+ 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'}`);
671
+ md.push('', `<sub>stet check ${checkNote}</sub>`);
672
+ }
673
+ if (g.lockIds.length) md.push('', `Touches files watched by ${g.lockIds.join(', ')}.`);
674
+ md.push('');
675
+ if (newDecisions.length) {
676
+ md.push('### Decisions made in this change');
677
+ for (const d of newDecisions) { const w = why(d.id); md.push(`- **${d.id}** ${d.title}${w ? ` — ${w}` : ''}`); }
678
+ md.push('');
679
+ }
680
+ md.push('---', '<sub>Generated by [Stet](https://github.com/shamazkhan/stet) from the git diff, the task scope and check results.</sub>', '');
681
+
682
+ const prText = md.join('\n');
683
+ r.write('.cache/pr.md', prText);
684
+ r.write('.cache/commit.txt', commit);
685
+ return out(prText, 0, { title, commit, pr: prText, files: files.length, violations: g.violations, checks: checkRes });
686
+ }
687
+
584
688
  // ---------- status ----------
585
689
  function status(r) {
586
690
  r.need();
@@ -597,24 +701,41 @@ function status(r) {
597
701
  }
598
702
 
599
703
  // ---------- git hook ----------
704
+ const HOOK_BEG = '# >>> stet >>>', HOOK_END = '# <<< stet <<<';
705
+ const STET_BIN = 'if command -v stet >/dev/null 2>&1; then STET="stet"; else STET="npx --no-install stet-cli"; fi';
706
+ function writeHook(hp, block) {
707
+ let cur = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : '';
708
+ if (cur.includes(HOOK_BEG)) cur = cur.slice(0, cur.indexOf(HOOK_BEG)) + cur.slice(cur.indexOf(HOOK_END) + HOOK_END.length + 1);
709
+ if (block == null) { if (fs.existsSync(hp)) fs.writeFileSync(hp, cur); return; }
710
+ const next = (cur.trim() ? cur.replace(/\s*$/, '\n') : '#!/bin/sh\n') + block;
711
+ fs.mkdirSync(path.dirname(hp), { recursive: true });
712
+ fs.writeFileSync(hp, next); fs.chmodSync(hp, 0o755);
713
+ }
600
714
  function hook(r, { uninstall, withCheck } = {}) {
601
715
  r.need();
602
716
  const gitDir = r.tgit(['rev-parse', '--git-path', 'hooks']).trim();
603
717
  if (!gitDir) throw new StetError('Not a git repo.');
604
- const hp = path.resolve(r.root, gitDir, 'pre-commit');
605
- const BEG = '# >>> stet >>>', FIN = '# <<< stet <<<';
606
- let cur = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : '';
607
- if (cur.includes(BEG)) cur = cur.slice(0, cur.indexOf(BEG)) + cur.slice(cur.indexOf(FIN) + FIN.length + 1);
608
- if (uninstall) { fs.writeFileSync(hp, cur); return out(`${OK()} Removed Stet from the pre-commit hook.`); }
609
- const block = `${BEG}
610
- if command -v stet >/dev/null 2>&1; then STET="stet"; else STET="npx --no-install stet"; fi
718
+ const pre = path.resolve(r.root, gitDir, 'pre-commit');
719
+ const msg = path.resolve(r.root, gitDir, 'prepare-commit-msg');
720
+ if (uninstall) { writeHook(pre, null); writeHook(msg, null); return out(`${OK()} Removed Stet from the git hooks.`); }
721
+ // pre-commit: block commits that stage out-of-scope or protected files.
722
+ writeHook(pre, `${HOOK_BEG}
723
+ ${STET_BIN}
611
724
  $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; }
612
- ${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}
613
- `;
614
- const next = (cur.trim() ? cur.replace(/\s*$/, '\n') : '#!/bin/sh\n') + block;
615
- fs.mkdirSync(path.dirname(hp), { recursive: true });
616
- fs.writeFileSync(hp, next); fs.chmodSync(hp, 0o755);
617
- 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)'}.`);
725
+ ${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}
726
+ `);
727
+ // prepare-commit-msg: prefix the ticket ID ("LIN-142: ...") unless the message already has it.
728
+ // $2 is empty (editor) or "message" (-m); merges, squashes and amends are left alone.
729
+ writeHook(msg, `${HOOK_BEG}
730
+ case "$2" in ""|message|template)
731
+ ${STET_BIN}
732
+ T=$($STET scope ticket --no-color 2>/dev/null)
733
+ if [ -n "$T" ] && ! grep -q "$T" "$1"; then { printf '%s: ' "$T"; cat "$1"; } > "$1.stet" && mv "$1.stet" "$1"; fi
734
+ ;;
735
+ esac
736
+ ${HOOK_END}
737
+ `);
738
+ 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: …"`);
618
739
  }
619
740
 
620
741
  // ---------- MCP config ----------
@@ -626,4 +747,4 @@ function mcpInstall(r) {
626
747
  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).`);
627
748
  }
628
749
 
629
- 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 };
750
+ 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/linear.js ADDED
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ // Optional Linear integration.
3
+ // The only network call Stet makes: when you ask for a ticket and LINEAR_API_KEY is set,
4
+ // it fetches that one issue from Linear. Nothing about your code is ever sent.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { StetError } = require('./core');
8
+
9
+ const LINEAR_URL = process.env.STET_LINEAR_URL || 'https://api.linear.app/graphql';
10
+ const ID_RE = /^[A-Z][A-Z0-9]{1,9}-\d+$/;
11
+
12
+ const QUERY = `query StetIssue($id: String!) {
13
+ issue(id: $id) {
14
+ identifier title description url priorityLabel
15
+ state { name } assignee { name } project { name }
16
+ labels { nodes { name } }
17
+ }
18
+ }`;
19
+
20
+ function apiKey() {
21
+ const k = (process.env.LINEAR_API_KEY || '').trim();
22
+ if (!k) {
23
+ throw new StetError(
24
+ 'LINEAR_API_KEY is not set.\n' +
25
+ ' 1. In Linear: Settings → Security & access → Personal API keys → New key (read access is enough)\n' +
26
+ ' 2. Add it to your shell, not to the repo: echo \'export LINEAR_API_KEY=lin_api_...\' >> ~/.zshrc && source ~/.zshrc'
27
+ );
28
+ }
29
+ return k;
30
+ }
31
+
32
+ // Personal API keys go in the header as-is; OAuth tokens need "Bearer".
33
+ const authHeader = (k) => (k.startsWith('lin_oauth') ? `Bearer ${k}` : k);
34
+
35
+ async function fetchIssue(id) {
36
+ id = String(id || '').trim().toUpperCase();
37
+ if (!ID_RE.test(id)) throw new StetError(`"${id}" doesn't look like a Linear ticket ID (expected something like LIN-142).`);
38
+ const key = apiKey();
39
+ let res;
40
+ try {
41
+ res = await fetch(LINEAR_URL, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader(key) },
44
+ body: JSON.stringify({ query: QUERY, variables: { id } }),
45
+ });
46
+ } catch (e) {
47
+ throw new StetError(`Couldn't reach Linear (${e.cause?.code || e.message}). Check your internet connection.`);
48
+ }
49
+ if (res.status === 401 || res.status === 403) throw new StetError('Linear rejected the API key. Check LINEAR_API_KEY (Settings → Security & access → Personal API keys).');
50
+ let body;
51
+ try { body = await res.json(); } catch { throw new StetError(`Linear returned an unexpected response (HTTP ${res.status}).`); }
52
+ const issue = body && body.data && body.data.issue;
53
+ if (!issue) {
54
+ const msg = (body && body.errors && body.errors[0] && body.errors[0].message) || 'not found';
55
+ throw new StetError(`Linear ticket ${id}: ${msg}. Check the ID and that your key's workspace can see it.`);
56
+ }
57
+ return issue;
58
+ }
59
+
60
+ // Cache lives in .stet/.cache (gitignored), so ticket text never ends up in the repo.
61
+ const cachePath = (r, id) => r.p('.cache', 'tickets', `${id}.json`);
62
+ function saveIssue(r, issue) {
63
+ const f = cachePath(r, issue.identifier);
64
+ fs.mkdirSync(path.dirname(f), { recursive: true });
65
+ fs.writeFileSync(f, JSON.stringify({ ...issue, fetchedAt: new Date().toISOString() }, null, 2) + '\n');
66
+ }
67
+ function cachedIssue(r, id) {
68
+ if (!id) return null;
69
+ try { return JSON.parse(fs.readFileSync(cachePath(r, id), 'utf8')); } catch { return null; }
70
+ }
71
+
72
+ // Markdown block used in the context pack and `stet ticket` output.
73
+ function issueMd(issue, { maxChars = 8000 } = {}) {
74
+ const meta = [
75
+ issue.state && issue.state.name && `Status: ${issue.state.name}`,
76
+ issue.priorityLabel && issue.priorityLabel !== 'No priority' && `Priority: ${issue.priorityLabel}`,
77
+ issue.assignee && issue.assignee.name && `Assignee: ${issue.assignee.name}`,
78
+ issue.project && issue.project.name && `Project: ${issue.project.name}`,
79
+ issue.labels && issue.labels.nodes && issue.labels.nodes.length && `Labels: ${issue.labels.nodes.map((l) => l.name).join(', ')}`,
80
+ ].filter(Boolean).join(' · ');
81
+ let desc = (issue.description || '').trim() || '_No description in Linear._';
82
+ if (desc.length > maxChars) desc = desc.slice(0, maxChars) + '\n\n… (ticket description truncated)';
83
+ return `## Ticket ${issue.identifier}: ${issue.title}\n${meta ? meta + '\n' : ''}${issue.url ? `Link: ${issue.url}\n` : ''}\n${desc}`;
84
+ }
85
+
86
+ module.exports = { fetchIssue, saveIssue, cachedIssue, issueMd, ID_RE };
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.1.3",
3
+ "version": "0.3.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"