tillerhand 3.28.0 → 3.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { execFileSync, spawnSync } from 'node:child_process';
11
11
  import { isTestFile } from './test-guard.mjs';
12
- import { isMain } from './shared.mjs';
12
+ import { findRoot, isMain } from './shared.mjs';
13
13
 
14
14
  export const BUDGET = { complexity: 15, function: 80, file: 400 };
15
15
  const LIZARD = ['pipx', 'run', 'lizard==1.24.1'];
@@ -41,7 +41,7 @@ export function changedFiles(root, base) {
41
41
  }
42
42
 
43
43
  function check() {
44
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
44
+ const root = findRoot();
45
45
  const i = process.argv.indexOf('--base');
46
46
  const files = selectFiles(changedFiles(root, i > 0 ? process.argv[i + 1] : ''));
47
47
  if (!files.length) { console.log('Karmaşıklık bütçesi: değişen kod dosyası yok.'); return; }
@@ -6,7 +6,7 @@
6
6
  // Rollback does not delete history: unsaved work is stashed first, files are set to the target state and
7
7
  // a NEW commit is made. So a rollback can be undone too. reset --hard is not used.
8
8
  import { execFileSync } from 'node:child_process';
9
- import { isMain } from './shared.mjs';
9
+ import { findRoot, isMain } from './shared.mjs';
10
10
 
11
11
  // Tag prefix in users' repos: it stays as it is, existing tags must keep matching
12
12
  const TAG_PREFIX = 'calisan/';
@@ -48,7 +48,7 @@ export function restore(root, target) {
48
48
  }
49
49
 
50
50
  if (isMain(import.meta.url)) {
51
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ const root = findRoot();
52
52
  const arg = process.argv.slice(2);
53
53
  // ONLY the first argument sets the mode: the permission list matches by prefix ("--list:*", "--save:*"),
54
54
  // so a trailing "--restore ... --yes" must not lead to an unapproved rollback.
@@ -20,15 +20,23 @@ const git = (c) => execSync(`git ${c}`, { cwd: root, encoding: 'utf8', stdio: ['
20
20
 
21
21
  // Changes that will go into the commit: staged, unstaged in tracked files (git commit -a) and untracked new
22
22
  // files (git add -A && git commit). -z: Turkish or spaced paths are not quoted and escaped
23
+ // The signature is each changed file's content on disk, so "git add" of the approved content keeps the approval and the gate.
24
+ // The commit takes the index: a file staged with other content than the disk (partial staging) also adds its index blob.
25
+ // --no-renames: a staged rename must list the same two paths as the unstaged delete + new file.
23
26
  export function changes() {
27
+ const list = (c) => git(c).split('\0').filter(Boolean);
24
28
  // .claude/state/ (approval, gate result) is not part of the signature: writing an approval must not change it even if not in .gitignore
25
- const untracked = git('ls-files --others --exclude-standard -z').split('\0').filter((f) => f && !f.startsWith('.claude/state/'));
26
- const files = [...new Set([...git('diff --cached --name-only -z').split('\0'), ...git('diff --name-only -z').split('\0'), ...untracked].filter(Boolean))];
27
- const h = crypto.createHash('sha256').update(git('diff --cached') + '\0' + git('diff'));
28
- for (const f of untracked) {
29
+ const untracked = list('ls-files --others --exclude-standard -z').filter((f) => !f.startsWith('.claude/state/'));
30
+ const staged = new Set(list('diff --cached --no-renames --name-only -z')), unstaged = new Set(list('diff --no-renames --name-only -z'));
31
+ const files = [...new Set([...staged, ...unstaged, ...untracked])].sort();
32
+ const partial = files.filter((f) => staged.has(f) && unstaged.has(f));
33
+ const blobs = new Map(partial.length ? list('ls-files -s -z').map((l) => [l.slice(l.indexOf('\t') + 1), l.split(' ')[1]]) : []);
34
+ const h = crypto.createHash('sha256');
35
+ for (const f of files) {
29
36
  h.update(`\0${f}\0`);
30
- try { h.update(fs.readFileSync(path.join(root, f))); } catch { /* unreadable file: the name is enough */ }
37
+ try { h.update(fs.readFileSync(path.join(root, f))); } catch { h.update('\0silindi'); }
31
38
  }
39
+ for (const f of partial) h.update(`\0index:${f}:${blobs.get(f)}`);
32
40
  return { files, signature: h.digest('hex') };
33
41
  }
34
42
 
@@ -98,11 +106,13 @@ function readApproval() {
98
106
 
99
107
  // This project's own commit-gate.mjs, resolved from the shell's folder ($CLAUDE_PROJECT_DIR, absolute, relative or ./ spelling),
100
108
  // is masked so it is not read as the gate approval; any other *gate.mjs (sandbox/.claude/hooks/commit-gate.mjs, a renamed
101
- // copy of gate.mjs) still is. A cd in the command moves the folder the text cannot follow: then nothing is masked.
109
+ // copy of gate.mjs) still is. Only a single plain "node <path> --approve <note>" is masked: a chain, subshell, cd, env -C or
110
+ // expansion can change the folder or the file the text names.
102
111
  const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
103
112
  const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
113
+ const PLAIN_APPROVE = /^\s*node\s+("[^"\n]*"|'[^'\n]*'|[^\s"'`;&|<>(){}]+)\s+--approve(\s+("[^"\n$`]*"|'[^'\n]*'|[^\s"'`$;&|<>(){}]+))?\s*$/;
104
114
  export function maskOwnHook(command, dir) {
105
- if (/(^|[;&|(]\s*)(cd|pushd|popd|Set-Location|sl|chdir)\b/i.test(command)) return command;
115
+ if (!PLAIN_APPROVE.test(command)) return command;
106
116
  const own = real(path.join(root, '.claude', 'hooks', 'commit-gate.mjs'));
107
117
  return command.replace(/"([^"\n]*commit-gate\.mjs)"|'([^'\n]*commit-gate\.mjs)'|([^\s"';&|]*commit-gate\.mjs)\b/gi, (m, a, b, c) => {
108
118
  const p = (a ?? b ?? c).replace(/\$\{?CLAUDE_PROJECT_DIR\}?|%CLAUDE_PROJECT_DIR%|\$env:CLAUDE_PROJECT_DIR/gi, process.env.CLAUDE_PROJECT_DIR || root);
@@ -294,14 +294,15 @@ function obfuscationOne(command) {
294
294
  ? { decision: 'ask', reason: 'Komut gizlenmiş görünüyor (satır sonu \\, $\'..\', $(...), `...` veya ${...}) ve korunan bir işlem içeriyor. Kullanıcı onayı gerekli.' } : null;
295
295
  }
296
296
 
297
- // A kit guard script (.claude/hooks) run with an indirect spelling: $VAR, $(..), `..`, ${..}, $'..' or a glob * ? (commit-gate.mjs $S,
297
+ // A kit guard script (.claude/hooks) run with an indirect spelling: $VAR, $(..), `..`, ${..}, $'..', a glob * ?, xargs, a pipe or < (commit-gate.mjs $S,
298
298
  // --sk$()ip, commit-*.mjs). The text rules for --skip, --approve and --plan-* cannot see through them: asked (user decision 2026-10-25).
299
299
  // Heredocs and single-quoted text are not expanded; $CLAUDE_PROJECT_DIR is the documented way to name the folder.
300
300
  export function hookScriptIssue(command) {
301
301
  const k = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '').replace(/'[^']*'/g, "''")
302
302
  .replace(/"(?:[^"\\]|\\.)*"/g, (q) => q.replace(/[*?]/g, '')).replace(/\$\{?CLAUDE_PROJECT_DIR\}?|\$env:CLAUDE_PROJECT_DIR/gi, '');
303
303
  if (!/\bnode\b/i.test(k) || !/\.claude|hooks|commit-|gate|freeze/i.test(command)) return null;
304
- return /\$\(|`|\$\{|\$'|\$[A-Za-z_]|[*?]/.test(k)
304
+ // Arguments from elsewhere count too: xargs, a pipe into node, an input redirect or <(...)
305
+ return /\$\(|`|\$\{|\$'|\$[A-Za-z_]|[*?]|\bxargs\b|\|[^|;&]*\bnode\b|<(?!<)/.test(k)
305
306
  ? { decision: 'ask', reason: 'Kitin koruma betiği dolaylı yazımla çalıştırılıyor ($DEĞİŞKEN, $(...), `...`, ${...} veya * ?); kural metni bunun içini göremez. Kullanıcı onayı gerekli.' } : null;
306
307
  }
307
308
 
@@ -6,7 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { spawn } from 'node:child_process';
9
- import { isMain } from './shared.mjs';
9
+ import { findRoot, isMain } from './shared.mjs';
10
10
 
11
11
  export const THRESHOLD_SEC = 90;
12
12
 
@@ -21,7 +21,7 @@ export function winCommand(title, message) {
21
21
  if (isMain(import.meta.url)) main();
22
22
 
23
23
  function main() {
24
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
24
+ const root = findRoot();
25
25
 
26
26
  let input = {};
27
27
  try { input = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { /* no input */ }
@@ -82,10 +82,15 @@ export function normalize(part) {
82
82
  return s;
83
83
  }
84
84
 
85
+ const NOT_REGISTRY = /^(\.|\/|file:|link:|git\+|git:|https?:|github:|workspace:)/;
85
86
  function npmName(arg) {
86
- if (/^(\.|\/|file:|link:|git\+|git:|https?:|github:|workspace:)/.test(arg)) return null;
87
- if (arg.startsWith('@')) { const i = arg.indexOf('@', 1); return i > 0 ? arg.slice(0, i) : arg; }
88
- return arg.split('@')[0] || null;
87
+ if (NOT_REGISTRY.test(arg)) return null;
88
+ const at = arg.indexOf('@', 1), spec = at > 0 ? arg.slice(at + 1) : '';
89
+ // name@npm:other installs "other": the alias target is checked. name@github:x/y, @https:… are not from the registry
90
+ // (like a bare URL, not checked here); the name in front is not what gets installed.
91
+ if (spec.startsWith('npm:')) return npmName(spec.slice(4));
92
+ if (/[:/]/.test(spec)) return null;
93
+ return (at > 0 ? arg.slice(0, at) : arg) || null;
89
94
  }
90
95
 
91
96
  function pypiName(arg) {
@@ -106,7 +111,7 @@ export function findPackages(command) {
106
111
  if (exec) {
107
112
  for (const { arg, fromFlag, unsure } of execArgs(args, eco, exec.positional)) {
108
113
  const name = eco === 'npm' ? npmName(arg) : pypiName(arg);
109
- if (name) result.push({ eco, name, exec: true, ...(exec.local && !fromFlag && { local: true }), ...(unsure && { unsure: true }) });
114
+ if (name) result.push({ eco, name, exec: true, spec: arg, ...(exec.local && !fromFlag && { local: true }), ...(unsure && { unsure: true }) });
110
115
  }
111
116
  continue;
112
117
  }
@@ -181,7 +186,8 @@ async function hook() {
181
186
  // npx/bunx run a bin the project already installed without downloading: a local bin (tsc, eas) is not a package name
182
187
  const bin = (name) => /^\w[\w.-]*$/.test(name) && [g.dir, g.root].some((d) => fs.existsSync(path.join(d, 'node_modules', '.bin', name)));
183
188
  // The kit's own package (/start and /adopt run npx -y tillerhand@<version> install): the kit already trusts itself
184
- const packages = findPackages(g.command).filter((p) => !(p.local && bin(p.name)) && !(p.eco === 'npm' && p.name === 'tillerhand'));
189
+ // Both only for the plain spelling: name@1.2 or name@npm:other downloads, it does not run the local bin or the kit's release
190
+ const packages = findPackages(g.command).filter((p) => !(p.local && p.spec === p.name && bin(p.name)) && !(p.eco === 'npm' && /^tillerhand(@\d+\.\d+\.\d+)?$/.test(p.spec ?? '')));
185
191
  if (!packages.length) process.exit(0);
186
192
  const results = await Promise.all(packages.map(async (p) => ({ ...p, ...(await check(p)) })));
187
193
  const missing = results.filter((s) => s.status === 'missing' && !s.unsure);
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { execFileSync } from 'node:child_process';
12
- import { isMain } from './shared.mjs';
12
+ import { findRoot, isMain } from './shared.mjs';
13
13
 
14
14
  // Windows paths are case-insensitive; other systems are case-sensitive
15
15
  const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p));
@@ -34,7 +34,7 @@ export function disabledHere(wanted, installed, root) {
34
34
  }
35
35
 
36
36
  if (isMain(import.meta.url)) {
37
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
37
+ const root = findRoot();
38
38
  // No shell: arguments go straight to claude (claude is a single-file executable, .exe on Windows)
39
39
  const run = (...arg) => execFileSync('claude', arg, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 180000 });
40
40
  const reason = (h) => (h.stderr || h.stdout || h.message || '').toString().trim().split('\n')[0];
@@ -4,9 +4,10 @@
4
4
  // Kept short: every reminder costs tokens.
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { findRoot } from './shared.mjs';
7
8
 
8
9
  export const INTERVAL = 12; // every 12 messages
9
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
+ const root = findRoot();
10
11
  const counterFile = path.join(root, '.claude', 'state', 'reminder.json');
11
12
 
12
13
  let data = { session: '', count: 0 };
@@ -7,9 +7,9 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execFileSync } from 'node:child_process';
10
- import { readInput, decide, isMain } from './shared.mjs';
10
+ import { readInput, decide, findRoot, isMain } from './shared.mjs';
11
11
 
12
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
12
+ const root = findRoot();
13
13
 
14
14
  // "**" any depth, "*" any name inside one folder, "?" one character
15
15
  export function globRegex(pattern) {
@@ -12,7 +12,7 @@ import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { missingPlugins } from './plugins-sync.mjs';
15
- import { isMain } from './shared.mjs';
15
+ import { findRoot, isMain } from './shared.mjs';
16
16
 
17
17
  // Keys are the package names users type (/tools ac tasarim): they stay Turkish
18
18
  export const PACKAGES = {
@@ -143,7 +143,7 @@ const tokenOf = (measurement, ids) => {
143
143
  };
144
144
  const NOTE = 'Açtıktan sonra /reload-plugins. Oturum ortasında açmak prompt önbelleğini bozar; en iyisi faz sınırında, /clear ile.';
145
145
  if (isMain(import.meta.url)) {
146
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
146
+ const root = findRoot();
147
147
  const [flag, name] = process.argv.slice(2);
148
148
  try {
149
149
  if (flag === '--enable' || flag === '--disable') {
@@ -6,7 +6,7 @@
6
6
  // node .claude/hooks/usage.mjs --json machine-readable
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { isMain } from './shared.mjs';
9
+ import { findRoot, isMain } from './shared.mjs';
10
10
 
11
11
  export const THRESHOLDS = { five_hour: { yellow: 60, red: 85 }, seven_day: { yellow: 70, red: 90 } };
12
12
  const WINDOWS = { five_hour: '5 saatlik', seven_day: 'haftalık' };
@@ -83,7 +83,7 @@ export function summary(data, now = Date.now()) {
83
83
  }
84
84
 
85
85
  if (isMain(import.meta.url)) {
86
- const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
86
+ const root = findRoot();
87
87
  const data = readLimits(root);
88
88
  if (process.argv.includes('--json')) console.log(JSON.stringify({ ...data, level: level(data), thresholds: THRESHOLDS }));
89
89
  else console.log(summary(data));
package/README.en.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="kit/CHANGELOG.md"><img alt="Version" src="https://img.shields.io/badge/version-3.28.0-2a78d6?style=flat-square"></a>
9
+ <a href="kit/CHANGELOG.md"><img alt="Version" src="https://img.shields.io/badge/version-3.28.1-2a78d6?style=flat-square"></a>
10
10
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-2a78d6?style=flat-square"></a>
11
11
  <img alt="Claude Code" src="https://img.shields.io/badge/Claude%20Code-ready-d97757?style=flat-square">
12
12
  <img alt="Also Codex and Antigravity" src="https://img.shields.io/badge/also-Codex%20%C2%B7%20Antigravity-5b6577?style=flat-square">
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="kit/CHANGELOG.md"><img alt="Sürüm" src="https://img.shields.io/badge/s%C3%BCr%C3%BCm-3.28.0-2a78d6?style=flat-square"></a>
9
+ <a href="kit/CHANGELOG.md"><img alt="Sürüm" src="https://img.shields.io/badge/s%C3%BCr%C3%BCm-3.28.1-2a78d6?style=flat-square"></a>
10
10
  <a href="LICENSE"><img alt="Lisans: MIT" src="https://img.shields.io/badge/lisans-MIT-2a78d6?style=flat-square"></a>
11
11
  <img alt="Claude Code" src="https://img.shields.io/badge/Claude%20Code-haz%C4%B1r-d97757?style=flat-square">
12
12
  <img alt="Codex ve Antigravity" src="https://img.shields.io/badge/ayr%C4%B1ca-Codex%20%C2%B7%20Antigravity-5b6577?style=flat-square">
package/kit/CHANGELOG.md CHANGED
@@ -3,6 +3,19 @@
3
3
  Kit sürümü `kit/VERSION` dosyasında. Bir projeyi güncellemek: projede `/kit-update`.
4
4
  "Elle uyarla" notu olan maddeler projeye ait dosyalardadır; güncelleme bunlara dokunmaz.
5
5
 
6
+ ## [3.28.1] - 2026-10-26
7
+
8
+ ### Düzeltilen
9
+ - **Onaylanan değişiklikleri `git add` ile sahnelemek commit kapısını ve kurul onayını geçersiz kılıyordu.** Değişiklik imzası artık dosyaların diskteki içeriğinden çıkıyor; aynı içeriği sahnelemek imzayı değiştirmiyor. Diskten farklı içerikle sahnelenen dosya (kısmi sahneleme) imzada ayrıca görünür, çünkü commit sahnedeki içeriği alır.
10
+ - **Kitin klon klasöründe `kit.mjs install` commit edilmemiş işi siliyordu** (`kit/` klasörü, `README.md`). Commit edilmemiş değişiklik varsa kurulum artık başlamadan durur ve dosyaları listeler.
11
+ - **Dokuz betik proje kökünü çalıştırıldığı klasörden buluyordu** (kayıt noktası, bütçe, bildirim, eklenti eşitleme, hatırlatıcı, yapı koruyucu, araçlar, limit). Codex alt klasörden çalıştırınca örneğin `checkpoint.mjs --restore` yalnız o klasörü geri alıyor ama "döndü" diyordu. Hepsi artık kökü aynı yoldan buluyor.
12
+ - **3.28.0'daki güvenlik düzeltmelerinde kalan açıklar:**
13
+ - npm takma adı (`zod@npm:baska-paket`) asıl kurulan paketi gizliyordu. Paket kapısı artık hedef paketi denetliyor. Bu açık 3.28.0'dan önce de vardı.
14
+ - `tillerhand` muafiyeti ve projede kurulu komut muafiyeti yalnız düz yazımda geçerli. `tillerhand@npm:…` ya da `tsc@5` gibi yazımlar denetleniyor.
15
+ - Commit kapısındaki muafiyet yalnız tek başına çalışan `node <yol> --approve <not>` komutu için geçerli. Zincir, alt kabuk ya da `env -C` içeren komutta muafiyet yok.
16
+ - Koruma betiğine argüman `xargs`, pipe ya da `<` ile verilirse kullanıcıya sorulur.
17
+ - Elle uyarla: yok.
18
+
6
19
  ## [3.28.0] - 2026-10-25
7
20
 
8
21
  ### Değişen
package/kit/VERSION CHANGED
@@ -1 +1 @@
1
- 3.28.0
1
+ 3.28.1
package/kit/guide.html CHANGED
@@ -287,7 +287,7 @@ footer .container { display: flex; flex-wrap: wrap; gap: 16px; justify-content:
287
287
  <section class="hero">
288
288
  <div class="container">
289
289
  <div>
290
- <span class="pill"><b>v3.28.0</b> Claude Code için başlangıç kiti · MIT</span>
290
+ <span class="pill"><b>v3.28.1</b> Claude Code için başlangıç kiti · MIT</span>
291
291
  <h1>Kodu AI yazar.<br><em>Dümen sende.</em></h1>
292
292
  <p class="sub">TillerHand, Claude Code ile bir ürünü fikirden canlıya ve bakıma kadar götürür. Her önemli kararda AI sana seçenekleri, önerisini ve her seçeneğin bedelini sunar. Bir uzman kurulu işi denetler, korumalar hatayı daha olmadan durdurur.</p>
293
293
  <div class="install"><span id="cmd">npx tillerhand init</span><button class="copy" type="button" data-copy="cmd">Kopyala</button></div>
package/kit/kit.mjs CHANGED
@@ -355,6 +355,11 @@ function install({ type, addons, keepKit, project, tool }) {
355
355
  if (project && path.resolve(project) !== ROOT) return installIntoProject(path.resolve(project), type, addons, tool);
356
356
  const tools = validateTools(tool);
357
357
  if (fs.existsSync(path.join(ROOT, '.kit.json'))) throw new Error('Kit bu projede zaten kurulu (.kit.json var).');
358
+ // This folder is about to lose kit/, README.md and the kit-only files: uncommitted work there could not be brought back
359
+ const st = spawnSync('git', ['status', '--porcelain', '--', '.'], { cwd: ROOT, encoding: 'utf8' });
360
+ if (st.status === 0 && st.stdout.trim()) {
361
+ throw new Error(`Commit edilmemiş değişiklik var; kurulum kit/ klasörünü siler ve README.md'yi değiştirir. Önce commit et ya da git stash ile kaydet:\n${st.stdout.trimEnd().split('\n').slice(0, 10).join('\n')}`);
362
+ }
358
363
 
359
364
  const { copies, patches } = layers(null, layerNames(type, addons));
360
365
  for (const [rel, source] of copies) copy(source, path.join(ROOT, rel));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tillerhand",
3
- "version": "3.28.0",
3
+ "version": "3.28.1",
4
4
  "description": "Claude Code, Codex ve Antigravity için Türkçe vibe coding kiti: kurallar, komutlar, korumalar ve proje şablonları.",
5
5
  "license": "MIT",
6
6
  "type": "module",