tillerhand 3.49.11 → 3.50.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.
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import crypto from 'node:crypto';
11
11
  import { execSync } from 'node:child_process';
12
- import { findRoot, isMain, reviewOff } from './shared.mjs';
12
+ import { findRoot, isMain, reviewOff, parseInput, decisionText } from './shared.mjs';
13
13
  import { t } from './lang.mjs';
14
14
  import { unquote } from './destructive-guard.mjs';
15
15
  import { isTestFile, isTestConfig, digest, readRecord } from './test-guard.mjs';
@@ -174,16 +174,27 @@ export function maskOwnHook(command, dir) {
174
174
  });
175
175
  }
176
176
 
177
+ // Claude Code stops a tool call with exit code 2 and the reason on stderr; Codex and Antigravity get their JSON decision
178
+ // (shared.decisionText; Codex has no ask, so an ask is a deny there and the user runs the command themselves).
179
+ let tool = 'claude';
180
+ const decideOut = (decision, reason) => { console.log(decisionText(tool, decision, reason)); process.exit(0); };
181
+ function stop(reason) {
182
+ if (tool !== 'claude') decideOut('deny', reason.trim());
183
+ process.stderr.write(reason);
184
+ process.exit(2);
185
+ }
186
+
177
187
  async function hook() {
178
188
  let command = '', dir = root;
179
189
  try {
180
- const input = JSON.parse(fs.readFileSync(0, 'utf8'));
190
+ const g = parseInput(JSON.parse(fs.readFileSync(0, 'utf8')) ?? {});
191
+ tool = g.tool;
181
192
  // A backslash-newline is a line continuation: bash joins the words ("git comm\<newline>it" is git commit), the
182
193
  // text rules may also need it as a space ("git commit\<newline>-m x"). Whichever spelling is a commit is checked.
183
- const raw = String(input.tool_input?.command ?? '');
194
+ const raw = String(g.command ?? '');
184
195
  const joined = raw.replace(/\\\r?\n/g, '');
185
196
  command = [joined, unquote(joined)].some((c) => commitPattern(joined).test(c)) ? joined : raw.replace(/\\\r?\n/g, ' ');
186
- if (typeof input.cwd === 'string' && input.cwd) dir = input.cwd;
197
+ if (g.dir) dir = g.dir;
187
198
  } catch {
188
199
  process.exit(0);
189
200
  }
@@ -191,11 +202,7 @@ async function hook() {
191
202
  // A commit whose message names --skip goes on to the commit checks below (a real --skip chained to a commit is stopped there).
192
203
  const open = unquote(command);
193
204
  const commit = commitPattern(command);
194
- if (!commit.test(command) && [command, open].some((c) => /commit-gate\.mjs\b[^;&|\n]*--skip\b/i.test(c))) {
195
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask',
196
- permissionDecisionReason: t(root, 'guard.commit.skipAsk') } }));
197
- process.exit(0);
198
- }
205
+ if (!commit.test(command) && [command, open].some((c) => /commit-gate\.mjs\b[^;&|\n]*--skip\b/i.test(c))) decideOut('ask', t(root, 'guard.commit.skipAsk'));
199
206
  // The gate command approval gives the commands in AGENTS.md permission to run: the user is asked (also covers spellings the permission list misses).
200
207
  // Only this project's commit-gate.mjs --approve is left out (maskOwnHook); both the raw and unquoted text are tested.
201
208
  const masked = maskOwnHook(command, dir);
@@ -203,26 +210,21 @@ async function hook() {
203
210
  // The prompt reads AGENTS.md now: a chain, an env variable, a node flag or a redirect must not change it after the
204
211
  // prompt and get another list approved. Only the exact spelling is accepted.
205
212
  if (!/^\s*node\s+(\.\/)?\.claude[\\/]hooks[\\/]gate\.mjs\s+(['"]?)--approve\2\s*$/.test(command)) {
206
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
207
- permissionDecisionReason: 'The gate command approval runs only with exactly this spelling, on its own (no chain, environment variable, flag or redirect): node .claude/hooks/gate.mjs --approve' } }));
208
- process.exit(0);
213
+ decideOut('deny', 'The gate command approval runs only with exactly this spelling, on its own (no chain, environment variable, flag or redirect): node .claude/hooks/gate.mjs --approve');
209
214
  }
210
215
  // Loaded only here: every Bash call runs this hook, and gate.mjs pulls in the dashboard and edit-round modules
211
216
  const { approvedCommands, commandList, readAgents, unknownCommandKeys } = await import('./gate.mjs');
212
217
  const list = commandList(approvedCommands(root)) || t(root, 'guard.commit.noCommands');
213
218
  const unknown = unknownCommandKeys(readAgents(root));
214
219
  const note = unknown.length ? `\n${t(root, 'guard.commit.gateUnknownKeys', { keys: unknown.join(', ') })}` : '';
215
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask',
216
- permissionDecisionReason: t(root, 'guard.commit.gateApprovalAsk', { list }) + note } }));
217
- process.exit(0);
220
+ decideOut('ask', t(root, 'guard.commit.gateApprovalAsk', { list }) + note);
218
221
  }
219
222
  if (!commit.test(command) && !commit.test(open)) process.exit(0);
220
223
  if (!hasBoard() || boardReviewOff()) process.exit(0);
221
224
  const chain = chainIssue(command, commit.inline ? COMMIT : commit);
222
225
  if (chain) {
223
- process.stderr.write(`Commit stopped: ${chain}. The gate reviews the changes as they were before the command runs; run the commit on its own ` +
226
+ stop(`Commit stopped: ${chain}. The gate reviews the changes as they were before the command runs; run the commit on its own ` +
224
227
  '(or only as "git add … && git commit"). Run push and other commands separately.\n');
225
- process.exit(2);
226
228
  }
227
229
 
228
230
  let d;
@@ -241,19 +243,19 @@ async function hook() {
241
243
  // Deleted files are left to the guards that ask before a delete. Only code files: snapshots, images and fixtures are
242
244
  // written by the test runner, not by Write/Edit. A partially staged test (git apply --cached, skip-worktree) commits an
243
245
  // index blob the record does not describe, so it always stops.
246
+ // Only Claude Code records what test-writer wrote; on Codex and Antigravity test separation is a written rule (AGENTS.md)
244
247
  const written = readRecord(root);
245
- const foreign = d.files.filter((f) => isTestFile(f) && isCodeFile(f) && !isTestConfig(f) && d.hashes[f] !== 'deleted' && written[f] !== d.hashes[f]);
248
+ const foreign = tool !== 'claude' ? [] : d.files.filter((f) => isTestFile(f) && isCodeFile(f) && !isTestConfig(f) && d.hashes[f] !== 'deleted' && written[f] !== d.hashes[f]);
246
249
  if (foreign.length && !(skipped && approval.signature === d.signature)) {
247
- process.stderr.write(`Commit stopped: test-writer did not write these test files, or they changed after it wrote them, or they were partially staged: ${foreign.join(', ')}.\n` +
250
+ stop(`Commit stopped: test-writer did not write these test files, or they changed after it wrote them, or they were partially staged: ${foreign.join(', ')}.\n` +
248
251
  'Only the test-writer agent writes test files with Write/Edit (AGENTS.md rule 7); a test written by patch, script, shell, git mv or Codex/Antigravity does not count.\n' +
249
252
  'If a change is needed, have test-writer review the file and rewrite it with Write/Edit. If the user made the change themselves ' +
250
253
  'and explicitly asks to skip the review for this commit, use --skip "user request".\n');
251
- process.exit(2);
252
254
  }
253
255
  if (approval?.signature === d.signature && (skipped || (gate?.signature === d.signature && gate.passed === true))) process.exit(0);
254
256
  if (approval?.signature === d.signature) {
255
257
  const failed = gate?.signature === d.signature ? gate.checks.filter((k) => k.status === 'failed').map((k) => k.name) : null;
256
- process.stderr.write(failed && !failed.length && gate.empty
258
+ stop(failed && !failed.length && gate.empty
257
259
  ? 'Commit stopped: the deterministic gate is empty (no check ran). Fill in AGENTS.md → Commands and run the gate again.\n' +
258
260
  'If the user explicitly asks for this commit, use --skip "user request".\n'
259
261
  : failed
@@ -261,22 +263,22 @@ async function hook() {
261
263
  'If the user explicitly asks for an intermediate (WIP) commit, use --skip "user request".\n'
262
264
  : 'Commit stopped: the deterministic gate has not run for these changes (or the changes were modified after it ran). ' +
263
265
  'Run node .claude/hooks/gate.mjs --run; if nothing is red, renew the board approval and try the commit again.\n');
264
- process.exit(2);
265
266
  }
266
267
 
267
268
  const delta = approval && !skipped ? sinceApproval(approval, d) : [];
268
- process.stderr.write(
269
+ stop(
269
270
  (delta.length && delta.length < d.files.length
270
271
  ? `Commit stopped: files changed since the last approval (${approval.note}) were not reviewed: ${delta.join(', ')}.\n` +
271
272
  'The board looks only at the diff of these files; files approved earlier are not reviewed again.\n'
272
273
  : `Commit stopped: the board has not reviewed these changes (${d.files.length} files).\n`) +
273
274
  '0. Run the deterministic gate (lint, types, tests, Semgrep) with node .claude/hooks/gate.mjs --run; give its output to the members.\n' +
274
- '1. Following .claude/board/rules.md §3, run at most 2 relevant board members for the changed files with model "haiku", on the diff only.\n' +
275
+ (tool === 'claude'
276
+ ? '1. Following .claude/board/rules.md §3, run at most 2 relevant board members for the changed files with model "haiku", on the diff only.\n'
277
+ : '1. Following .claude/board/rules.md §3, read the definitions of at most 2 relevant board members (.claude/agents/board-*.md) and review the diff as those roles yourself (single model).\n') +
275
278
  '2. If there is no REJECT: node .claude/hooks/commit-gate.mjs --approve "<members who took part>" and try the commit again.\n' +
276
279
  '3. If there is a REJECT, do not commit; ask the user about the finding and the options.\n' +
277
280
  'If the user explicitly wants to skip the review for this commit, use --skip "user request".\n'
278
281
  );
279
- process.exit(2);
280
282
  }
281
283
 
282
284
  if (isMain(import.meta.url)) {
@@ -11,11 +11,14 @@ import { readInput, decide, isMain, findRoot } from './shared.mjs';
11
11
  import { t } from './lang.mjs';
12
12
  import { isTestFile, isTestConfig } from './test-guard.mjs';
13
13
  import { readLock, readPlan, allowedInLock, inScope, realPath, relPath, protectedRule } from './freeze-guard.mjs';
14
+ import { clutterIssue } from './structure-guard.mjs';
14
15
 
15
16
  // Texts of ask decisions go to the user (message keys); texts of deny decisions are plain English for the AI.
16
17
  // Helpers here have no root argument: the project root comes from findRoot().
17
18
  const tr = (k, vars) => t(findRoot(), k, vars);
18
19
 
20
+ // _G_: options between command words (bounded, so a long repeated input stays linear); _L_: the rest of the command part
21
+ const cmdRe = (src) => new RegExp(src.replaceAll('_G_', String.raw`(?:[^;&|\n]{0,100}\s)?`).replaceAll('_L_', String.raw`[^;&|\n]{0,300}`), 'i');
19
22
  const DESTRUCTIVE = [
20
23
  [/\bdrop\s+(table|database|schema|index|view|collection)\b/i, 'DROP deletes a data structure'],
21
24
  [/\btruncate\s+(table\s+)?\w/i, 'TRUNCATE empties a table'],
@@ -29,6 +32,9 @@ const DESTRUCTIVE = [
29
32
  [/\bfirebase\s+firestore:delete\b/i, 'Firestore data is being deleted'],
30
33
  [/\bheroku\s+pg:reset\b|\bdrop(Database|Collection)\s*\(|\bdeleteMany\s*\(\s*\{\s*\}\s*\)|\bartisan\s+migrate:(fresh|refresh|reset)\b|\btypeorm\b[^;]*\bschema:drop\b|\bknex\b[^;]*\bmigrate:rollback\b[^;]*--all\b|\bprisma\s+db\s+push\b[^;]*--accept-data-loss\b/i, 'Database data is being deleted or reset'],
31
34
  [/\b(terraform|tofu|pulumi)\s+destroy\b/i, 'Cloud infrastructure is being destroyed'],
35
+ // Options may come before the service and between it and the subcommand (aws --profile x s3 --region r rm, heroku -a x apps:destroy)
36
+ // The flag is looked for once, before the gaps are tried: the rule stays fast on a long repeated input
37
+ [cmdRe(String.raw`\baws(\.exe)?\s(?=_L_\s--recursive\b)_G_s3\s_G_rm\b|\baws(\.exe)?\s(?=_L_\s--force\b)_G_s3\s_G_rb\b|\baws(\.exe)?\s_G_(s3api\s_G_delete-bucket|rds\s_G_delete-db-(instance|cluster)|dynamodb\s_G_delete-table|ec2\s_G_terminate-instances)\b|\bgcloud\s_G_((sql|compute)\s_G_instances\s_G_delete|projects\s_G_delete)\b|\b(gsutil|gcloud)\s(?=_L_\s(-[a-zA-Z]*r|--recursive)\b)_G_(storage\s_G_)?rm\b|\baz\s_G_(group|resource)\s_G_delete\b|\bheroku\s_G_apps:destroy\b|\b(fly|flyctl)\s_G_(apps|volumes)\s_G_destroy\b|\bgh\s_G_repo\s_G_delete\b|\bsupabase\s_G_projects\s_G_delete\b|\bnetlify(-cli)?\s_G_sites:delete\b`), 'Cloud data, an app or a repository is being deleted for good'],
32
38
  ];
33
39
 
34
40
  // Database addresses other than localhost count as prod (or a shared environment)
@@ -50,6 +56,11 @@ const NEEDS_APPROVAL = [
50
56
  // A package spelling (npx netlify-cli, firebase-tools, wrangler@3) deploys the same way
51
57
  [/\b(fly|flyctl)\s+deploy\b|\brailway\s+up\b|\bnetlify(-cli)?(@[\w.-]+)?\s+deploy\b[^;]*--prod|\bfirebase(-tools)?(@[\w.-]+)?\s+deploy\b|\bwrangler(@[\w.-]+)?\s+(deploy|publish|pages\s+deploy)\b|\bgcloud\s+app\s+deploy\b|\b(serverless|sls)\s+deploy\b|\bcdk\s+deploy\b|\bpulumi\s+up\b|\bamplify\s+(push|publish)\b|\b(terraform|tofu)\s+apply\b|\bexpo\s+publish\b/i, 'guard.destructive.serverDeploy'],
52
58
  [/\beas(-cli(@[\w.-]+)?)?\s+(submit\b|build\b(?=[^;\n]*--auto-submit\b)|update:(rollback|republish)\b|update\b(?=[^;\n]*--(branch|channel|environment)(=|\s+)['"]?prod(uction)?\b)|deploy\b(?=[^;\n]*--prod\b))/i, 'guard.destructive.storeSubmit'],
59
+ // vercel/vc only as a command word (vercel.json in a path is not one); delete as a whole word (delete.yaml is not)
60
+ [cmdRe(String.raw`\bkubectl(\.exe)?\s_G_delete(?=\s|$)|\bhelm(\.exe)?\s_G_(uninstall|delete)(?=\s|$)|\brailway\s_G_(down|delete)(?=\s|$)|(?<![\w./-])(vercel|vc)(@[\w.-]+)?(\.cmd|\.exe)?\s_G_(rm|remove)(?=\s|$)`), 'guard.destructive.resourceDelete'],
61
+ [cmdRe(String.raw`\b(npm|pnpm|bun|yarn)(\.cmd|\.exe)?\s_G_(npm\s+)?(publish|unpublish|deprecate|dist-tag)(?=\s|$)(?!_L_--dry-run(?!=false)(?!_L_--no-dry-run))|\bgh\s_G_release\s_G_(create|delete)\b|\bcargo\s_G_publish\b(?!_L_--dry-run)|\btwine\s_G_upload\b|\b(lerna|changeset)\s_G_publish\b`), 'guard.destructive.publish'],
62
+ [cmdRe(String.raw`\bgit(\.exe)?\s_G_branch\b(?=_L_\s(-[a-zA-Z]*d[a-zA-Z]*|--delete)(?=\s|$))(?=_L_\s(refs/heads/)?(main|master|production)(?=\s|$))`), 'guard.destructive.branchDelete'],
63
+ [cmdRe(String.raw`\bdocker(\.exe)?\s_G_(system|image)\s+prune\b(?=_L_\s(-[a-zA-Z]*a[a-zA-Z]*|--all)(?=\s|$))`), 'guard.destructive.dockerPrune'],
53
64
  [/\bgit\s+push\b[^;]*\b(main|master|production)\b/i, 'guard.destructive.pushMain'],
54
65
  [/\bgit\s+(rebase|filter-branch|filter-repo)\b/i, 'guard.destructive.rewriteHistory'],
55
66
  [/\b(alembic\s+upgrade|prisma\s+migrate\s+deploy|supabase\s+db\s+push)\b/i, 'guard.destructive.migration'],
@@ -395,7 +406,24 @@ const INLINE = /\b(node|deno|bun)\s+(-e|--eval|-p|--print)\b|\bpython[\d.]*\s+-c
395
406
  const TEST_WRITER_ONLY = 'Only the test-writer agent writes to test files: whoever writes the code cannot change tests (AGENTS.md rule 7). If a test must be added or changed, call the test-writer agent (continue it with SendMessage if it exists).';
396
407
  // ctx: { tool, agent } of the call; only Claude Code tells which subagent runs it. Test files are written by the
397
408
  // test-writer agent only; test config files stay a question (evaluateOne).
409
+ // Not every older rule is linear in the length: a command over MAX_COMMAND is not matched as a whole. Its short lines
410
+ // (up to 50 000 characters of them) still are, so a deny stays a deny; otherwise it is asked, naming a guarded word.
411
+ export const MAX_COMMAND = 10000;
412
+ export const shortLines = (command) => {
413
+ let total = 0;
414
+ return command.split(/\r?\n/).filter((l) => l.length <= 1000 && (total += l.length) <= 50000);
415
+ };
416
+ const GUARDED_WORD = /\b(drop|truncate|destroy|delete|reset|prune|flush|publish|deploy|push|rm|remove|rollback|promote|submit)\b/i;
417
+ function longCommand(command) {
418
+ const deny = shortLines(command).map((l) => strictest(evaluateOne(l), evaluateOne(unquote(l)), evaluateOne(blankQuoted(l))))
419
+ .find((r) => r?.decision === 'deny');
420
+ if (deny) return deny;
421
+ const word = command.match(GUARDED_WORD)?.[0];
422
+ return { decision: 'ask', reason: word ? tr('guard.destructive.tooLongWord', { length: command.length, word })
423
+ : tr('guard.destructive.tooLong', { length: command.length }) };
424
+ }
398
425
  export function evaluate(command, ctx = {}) {
426
+ if (command.length > MAX_COMMAND) return longCommand(command);
399
427
  const testWrite = ctx.tool === 'claude' && ctx.agent !== 'test-writer' && writesTest(quotedTextOff(command).replace(/\s+/g, ' '), isTestCode)
400
428
  ? { decision: 'deny', reason: TEST_WRITER_ONLY } : null;
401
429
  // An inline script (node -e, python -c, bash -c) that names a test path may write it; the shell rules cannot see inside
@@ -442,7 +470,8 @@ if (isMain(import.meta.url)) {
442
470
  // Of the lists, the stricter wins (deny > ask). A guard that throws would let the command run: asked instead
443
471
  let results;
444
472
  try {
445
- results = g ? [evaluate(g.command, g), g.tool !== 'claude' ? permissionEquivalent(g.command) : null, scopeIssue(g.command, g.dir, g.root)].filter(Boolean) : [];
473
+ results = !g ? [] : g.command.length > MAX_COMMAND
474
+ ? [evaluate(g.command, g), g.tool !== 'claude' ? strictest(...shortLines(g.command).map(permissionEquivalent)) : null].filter(Boolean) : [evaluate(g.command, g), g.tool !== 'claude' ? permissionEquivalent(g.command) : null, scopeIssue(g.command, g.dir, g.root), clutterIssue(g.command, g.dir, g.root)].filter(Boolean);
446
475
  } catch (e) {
447
476
  results = [{ decision: 'ask', reason: tr('guard.destructive.internalError', { error: String(e?.message ?? e).slice(0, 200) }) }];
448
477
  }
@@ -5,7 +5,10 @@
5
5
  // 2) Git pre-commit: `node .claude/hooks/guard-secrets.mjs --staged` scans the staged changes.
6
6
  // High-confidence patterns only; no generic "password=" search, to avoid false alarms.
7
7
  import { execSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
8
10
  import { readInput, decide, isMain } from './shared.mjs';
11
+ import { boardAfter } from './freeze-guard.mjs';
9
12
 
10
13
  // (?<![A-Za-z0-9]) instead of \b: a key right after _ (OPENAI_sk-…, prod_sk_live_…) is found too.
11
14
  // Lookaheads and runs are bounded: an unbounded one is quadratic on a long run (-sb_secret_ × 50 000) and a hook
@@ -31,6 +34,19 @@ export function findSecrets(text) {
31
34
  return PATTERNS.filter(([, re]) => re.test(text)).map(([name]) => name);
32
35
  }
33
36
 
37
+ // A key split across edits (MultiEdit, Antigravity chunks) or joined to the text around an edit only exists in the file
38
+ // after the write: the result is rebuilt and the keys it has that the file on disk did not have are returned.
39
+ function addedSecrets(f, dir) {
40
+ if (!f.edits) return [];
41
+ let disk;
42
+ try { disk = fs.readFileSync(path.resolve(dir, f.path), 'utf8'); } catch { return []; }
43
+ const after = boardAfter(disk, f);
44
+ if (after === null) return [];
45
+ const all = (s) => new Set(PATTERNS.flatMap(([name, re]) => [...s.matchAll(new RegExp(re.source, re.flags.replace('g', '') + 'g'))].map(([m]) => `${name}\0${m}`)));
46
+ const before = all(disk);
47
+ return [...all(after)].filter((k) => !before.has(k)).map((k) => k.split('\0')[0]);
48
+ }
49
+
34
50
  function hookMode() {
35
51
  const g = readInput();
36
52
  if (!g) process.exit(0); // unreadable input: do not block the work
@@ -38,11 +54,16 @@ function hookMode() {
38
54
  if (g.tool !== 'claude' && findSecrets(g.command).length) {
39
55
  decide(g.tool, 'deny', `BLOCKED: the command contains a secret (${findSecrets(g.command).join(', ')}). Do not write the secret into a command or a file; read it from an environment variable.`);
40
56
  }
41
- for (const { path, text } of g.files) {
42
- const found = findSecrets(text);
57
+ // Antigravity: every tool's string arguments, so a write tool the parser does not know (create_file…) is scanned too
58
+ if (g.tool === 'antigravity' && findSecrets(g.reads.join('\n')).length) {
59
+ decide(g.tool, 'deny', `BLOCKED: the tool call contains a secret (${findSecrets(g.reads.join('\n')).join(', ')}). Do not write the secret into a file; read it from an environment variable.`);
60
+ }
61
+ for (const f of g.files) {
62
+ const { path: file, text } = f;
63
+ const found = [...new Set([...findSecrets(text), ...addedSecrets(f, g.dir)])];
43
64
  if (found.length) {
44
65
  decide(g.tool, 'deny',
45
- `BLOCKED: a secret is being written to ${path} (${found.join(', ')}). ` +
66
+ `BLOCKED: a secret is being written to ${file} (${found.join(', ')}). ` +
46
67
  'Do not put the secret in code; read it from an environment variable and add the variable name to .env.example with an empty value.');
47
68
  }
48
69
  }
@@ -391,6 +391,12 @@ export const MESSAGES = {
391
391
  'guard.destructive.pushMain': { en: 'Direct push to the main branch' },
392
392
  'guard.destructive.rewriteHistory': { en: 'Git history is being rewritten' },
393
393
  'guard.destructive.migration': { en: 'A migration is being applied (check the target environment)' },
394
+ 'guard.destructive.resourceDelete': { en: 'A cluster resource, deployment, domain or environment variable is being deleted' },
395
+ 'guard.destructive.publish': { en: 'A package or a release is being published (or unpublished)' },
396
+ 'guard.destructive.branchDelete': { en: 'The main branch is being deleted' },
397
+ 'guard.destructive.dockerPrune': { en: 'Every unused Docker image is being deleted' },
398
+ 'guard.destructive.tooLong': { en: 'The command is too long for the shield to check ({length} characters)' },
399
+ 'guard.destructive.tooLongWord': { en: 'The command is too long for the shield to check ({length} characters) and contains "{word}"' },
394
400
  'guard.destructive.remoteDb': { en: 'The command connects to a database other than localhost (could be prod). User approval required; the AI must not use prod access credentials.' },
395
401
  'guard.destructive.testShell': { en: 'A test file is being changed, moved or deleted with a shell command. Weakening a test to make it pass is forbidden (AGENTS.md rule 7); edit tests with a file tool. User approval required.' },
396
402
  'guard.destructive.inlineTest': { en: 'An inline script names a test path; it may write to a test file. Only the test-writer agent writes to test files (AGENTS.md rule 7). User approval required.' },
@@ -449,8 +455,14 @@ export const MESSAGES = {
449
455
  'guard.package.depFileUnclear': { en: 'Package gate: the new dependencies in {path} could not be read (the file does not parse or the edit cannot be placed). User approval required.' },
450
456
  'guard.package.error': { en: 'The package gate could not check this command ({error}). User approval required.' },
451
457
  'guard.package.ask': { en: 'Package gate warning: {list}. Before installing, make sure the package is really the one you want.' },
458
+ 'guard.package.notRegistry': { en: 'not from the public registry ({source}); the gate cannot check it' },
459
+ 'guard.package.otherEco': { en: 'the gate does not check the {eco} registry' },
460
+ 'guard.package.configFile': { en: 'Package gate: {path} sets where packages are installed from (registry, index or credentials). User approval required.' },
452
461
  'guard.structure.offMap': { en: '"{rel}" is not in the structure map (.claude/structure.json). The right place is probably one of: {top}. If it is temporary or a trial, write it under sandbox/. If a new permanent place is needed, ask it as a technical decision and update the map (docs/architecture/structure.md).' },
453
462
  'guard.structure.prefix': { en: 'Structure: {reason}' },
463
+ 'guard.structure.longPath': { en: 'A path of {length} characters is not checked against the structure map (over 1024). User approval required.' },
464
+ 'guard.structure.brokenMap': { en: '.claude/structure.json does not parse, so "{rel}" cannot be checked against the map. Fix the map (it is JSON) or approve this file.' },
465
+ 'guard.structure.clutterShell': { en: 'The command creates "{rel}", which matches a clutter pattern (backup, old version, copy or temp file). Git history is already the backup; use sandbox/ for experiments. User approval required.' },
454
466
  'guard.test.skip': { en: 'a test is skipped or focused alone (.skip / .only / skipIf / xit / pytest skip)' },
455
467
  'guard.test.expectRemoved': { en: 'an expectation (expect/assert) is deleted or commented out' },
456
468
  'guard.test.expectWeak': { en: 'an expectation is loosened (toBeTruthy, toBeDefined, expect.anything, expect without a matcher, assert True)' },
@@ -30,9 +30,57 @@ const COMMANDS = [
30
30
  [/^(npm|pnpm|bun)\s+(i|in|ins|inst|insta|instal|install|isnt|isnta|isntal|isntall|it|add)\b/i, 'npm'], [/^yarn\s+(global\s+)?add\b/i, 'npm'], [/^(npx\s+)?expo\s+install\b/i, 'npm'],
31
31
  // create/init <name> downloads and runs create-<name>; deno takes npm:<name> specifiers
32
32
  [/^(npm|pnpm|yarn|bun)\s+(create|init)\b/i, 'npm', 'create'], [/^deno\s+(add|install|run|x)\b/i, 'npm', 'deno'],
33
- [/^uv\s+(add|pip\s+install|tool\s+install)\b/i, 'pypi'], [/^((python[\d.]*|py)(\s+-\d[\d.]*)?\s+-m\s+)?pip[\d.]*(\s+-\S+)*\s+install\b/i, 'pypi'],
33
+ // pip's own options can come before install, with a value (pip --index-url URL install x, pip --proxy P install x)
34
+ [/^uv\s+(add|pip\s+install|tool\s+install)\b/i, 'pypi'], [/^((python[\d.]*|py)(\s+-\d[\d.]*)?\s+-m\s+)?pip[\d.]*(\s+-\S+(\s+(?!install\b)[^\s-]\S*)?)*\s+install\b/i, 'pypi'],
34
35
  [/^(poetry|pdm)\s+add\b|^(pipenv|pipx)\s+install\b/i, 'pypi'],
36
+ // Registries the gate does not query: every package named there is asked (user decision 2026-10-30)
37
+ [/^cargo(\s+-\S+)*\s+(\+\S+\s+)?(add|install)\b/i, 'cargo'], [/^go\s+(get|install)\b/i, 'go'], [/^(gem\s+install|bundle\s+add)\b/i, 'gem'],
38
+ [/^composer\s+(global\s+)?require\b/i, 'composer'],
35
39
  ];
40
+ const OTHER_VALUE = /^(--path|--version|--vers|--features|-F|--rename|--package|-p|--branch|--tag|--rev|--registry|--index|--root|--target|--profile|-C|--manifest-path|-v|-i|--install-dir|-n|--bindir|--source|-s|--git|-j|--jobs)$/;
41
+
42
+ // Where an install argument comes from: 'local' (a path or file: link, already on this machine), 'remote' (git, a URL, a
43
+ // GitHub shorthand user/repo: not the registry the gate checks, user decision 2026-10-30), or null (a registry name).
44
+ // ~ is a path only as ~/ or ~\ (~1.2.3 is a semver range)
45
+ const LOCAL = /^(\.{1,2}([\\/]|$)|[\\/]|~([\\/]|$)|[A-Za-z]:[\\/]|file:|link:|workspace:|portal:)/i;
46
+ const REMOTE = /^(git\+|git:|ssh:|https?:|github:|gitlab:|bitbucket:|gist:)/i;
47
+ function npmSource(arg) {
48
+ for (;;) {
49
+ if (LOCAL.test(arg)) return 'local';
50
+ if (REMOTE.test(arg)) return 'remote';
51
+ const at = arg.indexOf('@', 1), spec = at > 0 ? arg.slice(at + 1) : '';
52
+ if (spec.startsWith('npm:')) { arg = spec.slice(4); continue; }
53
+ if (LOCAL.test(spec)) return 'local';
54
+ if (/[:/]/.test(spec)) return 'remote';
55
+ return !arg.startsWith('@') && arg.includes('/') ? 'remote' : null;
56
+ }
57
+ }
58
+ function pypiSource(arg) {
59
+ if (LOCAL.test(arg)) return 'local';
60
+ if (REMOTE.test(arg)) return 'remote';
61
+ const ref = arg.match(/^[^@;]*@\s*(\S+)/)?.[1];
62
+ if (ref) return LOCAL.test(ref) ? 'local' : 'remote';
63
+ return /\.(whl|tar\.gz|zip)$/i.test(arg) ? 'local' : null;
64
+ }
65
+ // A registry other than the public one: what gets installed cannot be checked. Read on the whole command, so a variable
66
+ // exported in an earlier statement (export PIP_INDEX_URL=…; pip install x) or a bare npm ci --registry … counts too.
67
+ // FLAG counts when the command runs a package manager; CONFIG (config set, a write to a registry config file) always.
68
+ // npm accepts an unambiguous prefix of a flag (--regist=…). Gaps are bounded: the text is linear to scan.
69
+ const MANAGER = /\b(npm|pnpm|yarn|bun|npx|bunx|pnpx|pip[\d.]*|uv|uvx|poetry|pdm|pipenv|pipx)\b/i;
70
+ // Registry config files: writing one points installs elsewhere (the file tools ask too, see gate())
71
+ export const REGISTRY_CONFIG = /(^|[\\/])(\.npmrc|\.yarnrc(\.yml)?|\.pnpmrc|bunfig\.toml|pip\.conf|pip\.ini|uv\.toml|poetry\.toml|\.pypirc)$/i;
72
+ const CONFIG_WRITE = /(>{1,2}|\b(tee|cp|copy|mv|move|ln|Set-Content|Add-Content|Out-File|Copy-Item|Move-Item|New-Item)\b[^;&|\n]{0,1000}?)\s*(-\w+\s+)?['"]?[^\s'";&|<>]*(\.npmrc|\.yarnrc(\.yml)?|\.pnpmrc|bunfig\.toml|pip\.conf|pip\.ini|uv\.toml|poetry\.toml|\.pypirc)(?![\w.-])/i;
73
+ const OTHER_REGISTRY = {
74
+ npm: {
75
+ FLAG: /(^|[\s'"])--((@[\w.-]+:)?reg[a-z]*|userconfig)(?:(?:=|\s+)\S*|$)|\b(npm_config_(@[\w.-]+:)?registry|npm_config_userconfig|YARN_NPM_REGISTRY_SERVER|YARN_REGISTRY)\s*=\S*/i,
76
+ CONFIG: new RegExp(`\\b(npm|pnpm|yarn|bun)\\b[^;&|\\n]{0,200}\\bset\\b[^;&|\\n]{0,200}(registry|RegistryServer)\\S*(\\s+\\S+)?|${CONFIG_WRITE.source}`, 'i'),
77
+ },
78
+ pypi: {
79
+ FLAG: /(^|[\s'"])(--(extra-)?index-url|--index|--default-index|--find-links)(?:(?:=|\s+)\S*|$)|\b(PIP_(EXTRA_)?INDEX_URL|PIP_FIND_LINKS|PIP_CONFIG_FILE|UV_(EXTRA_)?INDEX(_URL)?|UV_DEFAULT_INDEX|UV_FIND_LINKS|UV_CONFIG_FILE)\s*=\S*/i,
80
+ CONFIG: /\bpip[\d.]*\b[^;&|\n]{0,200}\bconfig\b[^;&|\n]{0,200}\bset\b[^;&|\n]{0,200}(index-url|find-links|trusted-host)\S*(\s+\S+)?|\bpoetry\s+(source\s+add|config\s+(repositories|http-basic|certificates)\.)\S*(\s+\S+)?/i,
81
+ },
82
+ };
83
+ const remote = (eco, spec) => ({ eco, name: '', spec, unverifiable: true });
36
84
  // create-vite for vite, @scope/create for @scope, @scope/create-x for @scope/x (npm's own mapping)
37
85
  function createName(arg) {
38
86
  const n = npmName(arg);
@@ -103,7 +151,7 @@ export function normalize(part) {
103
151
  return s;
104
152
  }
105
153
 
106
- const NOT_REGISTRY = /^(\.|\/|file:|link:|git\+|git:|https?:|github:|workspace:)/;
154
+ const NOT_REGISTRY = /^(\.|\/|~[\\/]|file:|link:|git\+|git:|https?:|github:|workspace:)/;
107
155
  function npmName(arg) {
108
156
  // name@npm:other installs "other": the alias target is checked (a loop: a deep a@npm:a@npm:… chain must not overflow
109
157
  // the stack). name@github:x/y, @https:… are not from the registry (like a bare URL, not checked here).
@@ -123,18 +171,37 @@ function pypiName(arg) {
123
171
 
124
172
  export function findPackages(command) {
125
173
  const result = [];
174
+ for (const [eco, { FLAG, CONFIG }] of Object.entries(OTHER_REGISTRY)) {
175
+ const m = command.match(CONFIG) ?? (MANAGER.test(command) ? command.match(FLAG) : null);
176
+ if (m) result.push(remote(eco, m[0].trim().replace(/^['"]+|['";]+$/g, '')));
177
+ }
126
178
  // Every place a new command can start: chains, a newline, a background &, $( ), backticks, ( ) and { } groups.
127
- // A redirect's & (2>&1, &>) is not a separator.
128
- for (const part of command.split(/\n|&&|\|\||;|\||(?<![>&])&(?![>&])|\$\(|`|[(){}]/)) {
179
+ // A redirect's & (2>&1, &>) is not a separator. A # comment is not an argument.
180
+ for (const raw of command.split(/\n|&&|\|\||;|\||(?<![>&])&(?![>&])|\$\(|`|[(){}]/)) {
181
+ // A # comment ends the part only outside quotes and after an unescaped blank (--prefix "a #b", a\ #b are values)
182
+ const cut = raw.replace(/"(?:[^"\\]|\\.)*"?|'[^']*'?|\\[\s\S]/g, (q) => '_'.repeat(q.length)).search(/(^|\s)#/);
183
+ const part = cut < 0 ? raw : raw.slice(0, cut);
129
184
  const s = normalize(part);
130
185
  const match = COMMANDS.find(([re]) => re.test(s));
131
186
  const exec = match ? null : EXEC.find((e) => e.re.test(s));
132
187
  if (!match && !exec) continue;
133
188
  const re = match?.[0] ?? exec.re, eco = match?.[1] ?? exec.eco;
134
- // Redirects (2>&1, > log, 2>/dev/null, &> x) are not arguments; a quoted "pkg>=1" has no space before ">" and stays
135
- const args = s.replace(re, '').replace(/\s+(\d*|&)(>>?|<)(&\d+|\s*[^\s'"]+)/g, '').trim().split(/\s+/).map((x) => x.replace(/^['"(]+|['")]+$/g, '')).filter(Boolean);
189
+ // Redirects (2>&1, > log, 2>/dev/null, &> x) are not arguments; a quoted "pkg>=1" has no space before ">" and stays.
190
+ // Words are split like the shell does: a quoted value with spaces ("a #b") is one word
191
+ const args = (s.replace(re, '').replace(/\s+(\d*|&)(>>?|<)(&\d+|\s*[^\s'"]+)/g, '').trim().match(/(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g) ?? [])
192
+ .map((x) => x.replace(/["']/g, '').replace(/^\(+|\)+$/g, '')).filter(Boolean);
193
+ if (!POPULAR[eco]) {
194
+ for (let i = 0; i < args.length; i++) {
195
+ const a = args[i];
196
+ if (a.startsWith('-')) { if (OTHER_VALUE.test(a) && args[++i] && a === '--git') result.push(remote(eco, args[i])); continue; }
197
+ if (!LOCAL.test(a)) result.push({ eco, name: a, unverifiable: true });
198
+ }
199
+ continue;
200
+ }
201
+ const source = eco === 'npm' ? npmSource : pypiSource;
136
202
  if (exec) {
137
203
  for (const { arg, fromFlag, unsure } of execArgs(args, eco, exec.positional)) {
204
+ if (source(arg) === 'remote') { result.push(remote(eco, arg)); continue; }
138
205
  const name = eco === 'npm' ? npmName(arg) : pypiName(arg);
139
206
  if (name) result.push({ eco, name, exec: true, spec: arg, ...(exec.local && !fromFlag && { local: true }), ...(unsure && { unsure: true }) });
140
207
  }
@@ -152,9 +219,23 @@ export function findPackages(command) {
152
219
  for (let i = 0; i < args.length; i++) {
153
220
  const a = args[i];
154
221
  if (a.startsWith('-')) {
155
- if (/^(-r|--requirement|-e|--editable|-c|--constraint|--index-url|-i|--extra-index-url|--filter|--prefix|--group)$/.test(a)) i++; // flag that takes a value
222
+ if (eco === 'pypi') {
223
+ // pip short options take their value attached too: -ihttps://…, -fhttps://…, -egit+https://…, --editable=git+…
224
+ if (/^-[if]./.test(a)) result.push(remote(eco, a));
225
+ const v = a.match(/^(?:-[erc]|--(?:editable|requirement|constraint)=)(.+)$/)?.[1];
226
+ if (v && pypiSource(v) === 'remote') result.push(remote(eco, v));
227
+ }
228
+ // npm's -f is --force (no value): the pip-only flags count only for pip
229
+ if (/^(-r|--requirement|-e|--editable|-c|--constraint|--index-url|-i|--extra-index-url|--filter|--prefix|--group)$/.test(a)
230
+ || (eco === 'pypi' ? /^(-f|--find-links|--index|--default-index|--trusted-host|--source|-t|--target|--root|--platform|--python-version)$/ : /^--registry$/).test(a)) {
231
+ const v = args[i + 1];
232
+ // -e git+https://…, -r https://… install from a source the gate cannot check; so do -i/-f
233
+ if (eco === 'pypi' && v && (/^(-[if])$/.test(a) || (/^(-e|--editable|-r|--requirement|-c|--constraint)$/.test(a) && pypiSource(v) === 'remote'))) result.push(remote(eco, `${a} ${v}`));
234
+ i++; // flag that takes a value
235
+ }
156
236
  continue;
157
237
  }
238
+ if (source(a) === 'remote') { result.push(remote(eco, a)); continue; }
158
239
  const name = eco === 'npm' ? npmName(a) : pypiName(a);
159
240
  if (name) result.push({ eco, name });
160
241
  }
@@ -166,41 +247,137 @@ export function findPackages(command) {
166
247
  // 2026-10-30), so "edit package.json, then a bare npm ci" cannot skip the gate. null: the result does not parse (asked).
167
248
  const DEP_FILE = /(^|\/)(package\.json|pyproject\.toml|requirements[^/]*\.txt|requirements\/[^/]+\.txt)$/i;
168
249
  const NPM_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
169
- // An npm: alias installs its target: "x": "npm:evil@1" adds evil
170
- const npmDeps = (j) => new Set(NPM_SECTIONS.flatMap((k) => Object.entries(j?.[k] && typeof j[k] === 'object' ? j[k] : {})
171
- .map(([n, v]) => (typeof v === 'string' && /^npm:/i.test(v) ? npmName(v.slice(4)) : n))).filter(Boolean));
172
- const requirementNames = (text) => new Set(text.split(/\r?\n/).map((s) => s.trim())
173
- .filter((s) => s && !s.startsWith('#') && !s.startsWith('-')).map(pypiName).filter(Boolean));
174
- function pyprojectNames(text) {
175
- const names = new Set();
176
- let section = '', inArray = false;
250
+ const obj = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x : {});
251
+ // A package name without its version (foo@1.2 → foo, @s/x@1 → @s/x)
252
+ const bare = (n) => n.replace(/(?<=.)@.*$/, '');
253
+ // Override keys: npm overrides nest ({ foo: { ".": "1", bar: "2" } }), yarn resolutions are paths (**/bar, @s/a/b),
254
+ // pnpm.overrides chain names (foo@<2>bar, foo>bar)
255
+ function overrideDeps(o, out) {
256
+ for (const [k, v] of Object.entries(obj(o))) {
257
+ if (k === '.') continue;
258
+ out.push([bare(k), typeof v === 'string' ? v : typeof obj(v)['.'] === 'string' ? v['.'] : '']);
259
+ if (v && typeof v === 'object') overrideDeps(v, out);
260
+ }
261
+ }
262
+ function resolutionName(k) {
263
+ const parts = k.split('/').filter((p) => p && p !== '**');
264
+ let last = parts.pop() ?? '';
265
+ if (parts.at(-1)?.startsWith('@')) last = `${parts.pop()}/${last}`;
266
+ return bare(last);
267
+ }
268
+ // Every npm dependency the file declares, as { name, spec, remote }: an npm: alias names its target ("x": "npm:evil@1" adds evil);
269
+ // a local spec (file:, link:, workspace:) is left out; a git, URL or user/repo spec is remote
270
+ function npmDeps(j) {
271
+ const pairs = NPM_SECTIONS.flatMap((k) => Object.entries(obj(j?.[k])));
272
+ overrideDeps(j?.overrides, pairs);
273
+ for (const [k, v] of Object.entries(obj(j?.resolutions))) pairs.push([resolutionName(k), v]);
274
+ for (const [k, v] of Object.entries(obj(j?.pnpm?.overrides))) for (const n of k.split('>')) pairs.push([bare(n), n === k.split('>').at(-1) ? v : '']);
275
+ const out = [];
276
+ for (const [n, v] of pairs) {
277
+ const spec = typeof v === 'string' ? v : '';
278
+ // pnpm catalog: and yarn patch: resolve to the registry package of the same name
279
+ const arg = /^npm:/i.test(spec) ? spec.slice(4) : spec && !/^(catalog|patch):/i.test(spec) ? `${n}@${spec}` : n;
280
+ const source = npmSource(arg);
281
+ if (source === 'remote') out.push({ name: n, spec, remote: true });
282
+ else if (!source && !/^\$/.test(spec)) { const name = npmName(arg); if (name) out.push({ name, spec }); }
283
+ }
284
+ return out;
285
+ }
286
+ // requirements lines: a name, a git/URL line or an index/find-links option (remote), local paths and -r/-c are left out
287
+ function requirementDeps(text) {
288
+ const out = [];
177
289
  for (const raw of text.split(/\r?\n/)) {
290
+ const s = raw.replace(/(^|\s)#.*$/, '').trim();
291
+ if (!s) continue;
292
+ // Short options may carry their value attached (-ihttps://…, -egit+https://…)
293
+ const opt = s.match(/^(--editable|--index-url|--extra-index-url|--find-links|--requirement|--constraint|-[eifrc])(?:=|\s+)?(.*)$/);
294
+ if (opt) {
295
+ if (/^(-[if]|--index-url|--extra-index-url|--find-links)$/.test(opt[1]) || pypiSource(opt[2]) === 'remote') out.push({ name: '', spec: s, remote: true });
296
+ continue;
297
+ }
298
+ if (s.startsWith('-')) continue;
299
+ out.push(...pep508(s));
300
+ }
301
+ return out;
302
+ }
303
+ const pep508 = (s) => {
304
+ const source = pypiSource(s);
305
+ if (source === 'remote') return [{ name: pypiName(s.replace(REMOTE, '')) ?? '', spec: s, remote: true }];
306
+ if (source === 'local') return [];
307
+ const name = pypiName(s);
308
+ return name ? [{ name, spec: s }] : [];
309
+ };
310
+ // pyproject.toml, by full dotted key (section + key, so [project] optional-dependencies.dev = […], a top-level
311
+ // project.dependencies = […] and a quoted ["project"] header read the same):
312
+ // arrays of requirements: PY_ARRAY; one table entry per package (Poetry dependencies, uv sources): PY_TABLE, also as a
313
+ // sub-table header [tool.poetry.dependencies.x]; a custom index URL ([[tool.uv.index]], [[tool.poetry.source]]): PY_INDEX.
314
+ // Array items are read token by token: a quoted "]" does not end the array, an { include-group = "x" } table is not a
315
+ // name, a # comment ends the line.
316
+ const PY_ARRAY = /^(project\.dependencies|project\.optional-dependencies\.[^.]+|dependency-groups\.[^.]+|tool\.uv\.(dev|constraint|override)-dependencies|build-system\.requires|tool\.pdm\.dev-dependencies\.[^.]+|tool\.hatch\.envs\.[^.]+\.(extra-)?dependencies)$/;
317
+ const PY_TABLE = /^(tool\.poetry(\.group\.[^.]+)?\.(dev-)?dependencies|tool\.uv\.sources)\.([^.]+)$/;
318
+ const PY_INDEX = /^(tool\.uv\.index|tool\.poetry\.source|tool\.pdm\.source)\.url$/;
319
+ // A uv source only redirects a declared package: it adds no name, only a remote spec
320
+ const tableEntry = (full, value) => {
321
+ const t = full.match(PY_TABLE), name = t?.[4].toLowerCase();
322
+ if (!t || name === 'python') return [];
323
+ if (/\b(git|url)\s*=/.test(value)) return [{ name, spec: value, remote: true }];
324
+ return /\bpath\s*=/.test(value) || t[1] === 'tool.uv.sources' ? [] : [{ name, spec: '' }];
325
+ };
326
+ function pyprojectDeps(text) {
327
+ const out = [];
328
+ let section = '', inArray = false, table = null;
329
+ // [tool.poetry.dependencies.x] declares x with its keys on the next lines: decided when the section ends
330
+ const flush = () => { if (table !== null) out.push(...tableEntry(section, table)); table = null; };
331
+ for (const raw of [...text.split(/\r?\n/), '[]']) {
178
332
  const line = raw.trim();
179
- const head = line.match(/^\[([^\]"']+)\]$/);
180
- if (head) { section = head[1].trim(); inArray = false; continue; }
181
- if (section === 'project') {
182
- if (/^dependencies\s*=\s*\[/.test(line)) inArray = true;
183
- if (!inArray) continue;
184
- for (const m of line.matchAll(/["']([^"']+)["']/g)) { const n = pypiName(m[1]); if (n) names.add(n); }
185
- if (line.includes(']')) inArray = false;
186
- } else if (/^tool\.poetry(\.group\.[\w-]+)?\.(dev-)?dependencies$/.test(section)) {
187
- const k = line.match(/^["']?([A-Za-z0-9][\w.-]*)["']?\s*=/);
188
- if (k && k[1].toLowerCase() !== 'python') names.add(k[1].toLowerCase());
333
+ const head = inArray ? null : line.match(/^\[\[?([^\]]*)\]\]?$/);
334
+ if (head) {
335
+ flush();
336
+ section = head[1].replace(/["'\s]/g, '');
337
+ if (PY_TABLE.test(section)) table = '';
338
+ continue;
339
+ }
340
+ let rest = line;
341
+ if (!inArray) {
342
+ const key = line.match(/^((?:"[^"]*"|'[^']*'|[\w.-]+)(?:\s*\.\s*(?:"[^"]*"|'[^']*'|[\w.-]+))*)\s*=\s*(.*)$/);
343
+ if (!key) continue;
344
+ const k = key[1].replace(/["'\s]/g, ''), full = section ? `${section}.${k}` : k, value = key[2];
345
+ if (PY_ARRAY.test(full) && value.startsWith('[')) { inArray = true; rest = value.slice(1); }
346
+ else {
347
+ if (PY_TABLE.test(full)) out.push(...tableEntry(full, value));
348
+ else if (table !== null && /^(git|url|path)$/.test(k)) table += ` ${k} = ${value}`;
349
+ else if (PY_INDEX.test(full)) out.push({ name: '', spec: value, remote: true });
350
+ continue;
351
+ }
352
+ }
353
+ for (const [tok] of rest.matchAll(/"(?:[^"\\]|\\.)*"|'[^']*'|\{[^}]*\}?|\]|#.*$/g)) {
354
+ if (tok === ']') { inArray = false; break; }
355
+ if (tok.startsWith('#')) break;
356
+ if (tok.startsWith('{')) continue;
357
+ out.push(...pep508(tok.slice(1, -1)));
189
358
  }
190
359
  }
191
- return names;
360
+ return out;
192
361
  }
362
+ // What the write adds: a registry name not declared before (a version change is not checked), or a remote spec not there
363
+ // before (asked, never checked). Returns [{ eco, name, spec?, unverifiable? }] or null when the result does not parse.
193
364
  export function addedDependencies(file, before, after) {
194
365
  const rel = file.replace(/\\/g, '/');
195
- const added = (eco, a, b) => [...a].filter((n) => !b.has(n)).map((name) => ({ eco, name }));
366
+ const added = (eco, a, b) => {
367
+ const names = new Set(b.filter((d) => !d.remote).map((d) => d.name)), specs = new Set(b.map((d) => `${d.name}\0${d.spec}`));
368
+ const seen = new Set();
369
+ return a.filter((d) => (d.remote ? !specs.has(`${d.name}\0${d.spec}`) : !names.has(d.name)))
370
+ .filter((d) => { const k = `${d.remote}\0${d.name}\0${d.remote ? d.spec : ''}`; return !seen.has(k) && seen.add(k); })
371
+ .map((d) => (d.remote ? { eco, name: d.name, spec: d.spec, unverifiable: true } : { eco, name: d.name }));
372
+ };
196
373
  if (/(^|\/)package\.json$/i.test(rel)) {
197
374
  let a, b = {};
198
375
  try { a = JSON.parse(after); } catch { return null; }
199
376
  try { b = JSON.parse(before || '{}'); } catch { /* a broken file before: every name counts as added */ }
200
377
  return added('npm', npmDeps(a), npmDeps(b));
201
378
  }
202
- if (/(^|\/)requirements[^/]*\.txt$|(^|\/)requirements\/[^/]+\.txt$/i.test(rel)) return added('pypi', requirementNames(after), requirementNames(before));
203
- if (/(^|\/)pyproject\.toml$/i.test(rel)) return added('pypi', pyprojectNames(after), pyprojectNames(before));
379
+ if (/(^|\/)requirements[^/]*\.txt$|(^|\/)requirements\/[^/]+\.txt$/i.test(rel)) return added('pypi', requirementDeps(after), requirementDeps(before));
380
+ if (/(^|\/)pyproject\.toml$/i.test(rel)) return added('pypi', pyprojectDeps(after), pyprojectDeps(before));
204
381
  return [];
205
382
  }
206
383
 
@@ -273,6 +450,7 @@ async function gate(g) {
273
450
  // 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
274
451
  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 ?? '')));
275
452
  for (const f of g.files) {
453
+ if (REGISTRY_CONFIG.test(String(f.path))) decide(g.tool, 'ask', t(g.root, 'guard.package.configFile', { path: f.path }));
276
454
  if (!DEP_FILE.test(String(f.path).replace(/\\/g, '/'))) continue;
277
455
  const full = path.resolve(g.dir, f.path);
278
456
  let disk = ''; try { disk = fs.readFileSync(full, 'utf8'); } catch { /* new file */ }
@@ -283,14 +461,17 @@ async function gate(g) {
283
461
  }
284
462
  if (!packages.length) return;
285
463
  if (packages.length > MAX_PACKAGES) decide(g.tool, 'ask', t(g.root, 'guard.package.tooMany', { count: packages.length, max: MAX_PACKAGES }));
286
- const results = await Promise.all(packages.map(async (p) => ({ ...p, ...(await check(p)) })));
464
+ // A remote source or a registry the gate does not query is asked as is, without a fetch
465
+ const results = await Promise.all(packages.map(async (p) => ({ ...p, ...(p.unverifiable
466
+ ? { status: 'unverifiable', detail: POPULAR[p.eco] ? t(g.root, 'guard.package.notRegistry', { source: p.spec || p.name }) : t(g.root, 'guard.package.otherEco', { eco: p.eco }) }
467
+ : await check(p)) })));
287
468
  const missing = results.filter((s) => s.status === 'missing' && !s.unsure);
288
469
  if (missing.length) {
289
470
  decide(g.tool, 'deny', `Package gate: ${missing.map((s) => `${s.name} (${s.detail})`).join(', ')}. This name may be made up. Find the correct package name in the official docs; tell the user.`);
290
471
  }
291
472
  const suspect = results.filter((s) => s.status !== 'ok');
292
473
  if (suspect.length) {
293
- decide(g.tool, 'ask', t(g.root, 'guard.package.ask', { list: suspect.map((s) => `${s.eco}:${s.name} — ${s.detail}`).join('; ') }));
474
+ decide(g.tool, 'ask', t(g.root, 'guard.package.ask', { list: suspect.map((s) => `${s.eco}:${s.name || s.spec} — ${s.detail}`).join('; ') }));
294
475
  }
295
476
  process.exit(0);
296
477
  }
@@ -97,7 +97,8 @@ export function parseInput(g) {
97
97
  }
98
98
  const t = g.tool_input ?? {};
99
99
  const command = isText(t.command) ? t.command : Array.isArray(t.command) ? t.command.join(' ') : t.command == null ? '' : JSON.stringify(t.command);
100
- if (g.tool_name === 'apply_patch') result.files = patchFiles(command);
100
+ // Codex can pass the patch text in tool_input.input instead of command
101
+ if (g.tool_name === 'apply_patch') result.files = patchFiles(command || (isText(t.input) ? t.input : t.input == null ? '' : JSON.stringify(t.input)));
101
102
  else result.command = command;
102
103
  const file = t.file_path ?? t.notebook_path;
103
104
  if (file) {
@@ -27,19 +27,25 @@ export function globRegex(pattern) {
27
27
  }
28
28
 
29
29
  export function readMap(dir = root) {
30
+ let text;
31
+ try { text = fs.readFileSync(path.join(dir, '.claude', 'structure.json'), 'utf8'); } catch { return null; }
32
+ // A map that exists but does not parse must not switch the guard off: every new file is asked
30
33
  try {
31
- const m = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'structure.json'), 'utf8'));
34
+ const m = JSON.parse(text);
32
35
  return { allow: (m.allow ?? []).map(globRegex), deny: (m.deny ?? []).map(globRegex), raw: m };
33
36
  } catch {
34
- return null;
37
+ return { broken: true };
35
38
  }
36
39
  }
37
40
 
38
41
  // Returns: null (fine) | { decision: 'deny' | 'ask', reason }
39
42
  export function check(rel, map) {
40
43
  if (!map) return null;
44
+ // The globs are not linear in the path length: a very long path is asked, not matched
45
+ if (rel.length > 1024) return { decision: 'ask', reason: t(root, 'guard.structure.longPath', { length: rel.length }) };
41
46
  rel = rel.replace(/\\/g, '/').replace(/^\.\//, '');
42
47
  if (rel.startsWith('sandbox/')) return null;
48
+ if (map.broken) return { decision: 'ask', reason: t(root, 'guard.structure.brokenMap', { rel }) };
43
49
  const denied = map.deny.find((re) => re.test(rel));
44
50
  if (denied) {
45
51
  return { decision: 'deny', reason: `"${rel}" matches a clutter pattern (backup, old version, copy or temp file). Git history is already the backup; use sandbox/ for experiments.` };
@@ -52,6 +58,46 @@ export function check(rel, map) {
52
58
  };
53
59
  }
54
60
 
61
+ // Shell side, clutter only: a file a command creates that does not exist yet (a redirect target, the destination of
62
+ // cp/mv/copy/ln, the arguments of tee, touch, New-Item, Out-File, Set-Content) is checked against the deny list.
63
+ // Asked, not denied: the shell text is read by heuristics. Off-map files are not asked here (the Write hook does that).
64
+ // No map or a broken map: silent.
65
+ // shortcut: text-based (quoted words, variables, globs and bash -c bodies are skipped; cd followed in order only).
66
+ const CREATORS = /^(cp|copy|mv|move|ln|copy-item|move-item|cpi|mi)$/;
67
+ const MAKERS = /^(tee|touch|new-item|ni|out-file|set-content|add-content)$/;
68
+ const PREFIX = /^(\w+=\S*|sudo|env|time|nohup|command|exec|do|then|else)$/i;
69
+ const HIDDEN = ' <>;&|#=()\n';
70
+ export function clutterIssue(command, dir, root) {
71
+ const map = readMap(root);
72
+ if (!map || map.broken) return null;
73
+ // Text is not a target: a heredoc body goes (its first line, with its redirects, stays); a quoted string becomes one
74
+ // word, its blanks and shell characters hidden in private-use characters (U+E000…) until it is used as a path, so
75
+ // "a > b.bak" is no redirect but "a (1).ts" is still a name; arrows (=>, ->) go; >| >& &> are plain redirects
76
+ const text = command.replace(/(<<-?\s*(['"]?)(\w+)\2[^\n]*)\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g, '$1')
77
+ .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, (q) => q.slice(1, -1).replace(/[\s<>;&|#=()]/g, (c) => String.fromCharCode(0xe000 + HIDDEN.indexOf(c === '\t' ? ' ' : c))))
78
+ .replace(/[=-]>/g, ' ').replace(/\d*>&\d+/g, ' ').replace(/>\||>&|&>/g, '>');
79
+ let cwd = dir;
80
+ for (const raw of text.split(/&&|\|\||[;|\n&]/)) {
81
+ const part = raw.replace(/(^|\s)#.*$/, '').replace(/^[\s({!]+|[\s)}]+$/g, '');
82
+ const redirects = [...part.matchAll(/\d?>{1,2}\s*([^\s;&|<>]+)/g)].map((m) => m[1]);
83
+ const words = part.replace(/\d?>{1,2}\s*\S+/g, ' ').trim().split(/\s+/);
84
+ while (words.length && PREFIX.test(words[0])) words.shift();
85
+ const name = path.basename(words[0] ?? '').toLowerCase().replace(/\.(exe|cmd)$/, '');
86
+ const dest = words.findIndex((w) => /^-destination$/i.test(w));
87
+ const args = words.slice(1).filter((w) => w && !w.startsWith('-'));
88
+ if (/^(cd|pushd|set-location)$/.test(name)) { cwd = path.resolve(cwd, args[0] ?? '.'); continue; }
89
+ const made = CREATORS.test(name) ? (dest > -1 ? [words[dest + 1] ?? ''] : args.slice(-1)) : MAKERS.test(name) ? args : [];
90
+ for (const w of [...redirects, ...made]) {
91
+ if (!w || /[$`*?{]/.test(w)) continue;
92
+ const x = w.replace(/[-]/g, (c) => HIDDEN[c.charCodeAt(0) - 0xe000]);
93
+ const full = path.resolve(cwd, x), rel = path.relative(root, full);
94
+ if (rel.startsWith('..') || path.isAbsolute(rel) || fs.existsSync(full)) continue;
95
+ if (check(rel, map)?.decision === 'deny') return { decision: 'ask', reason: t(root, 'guard.structure.clutterShell', { rel: rel.replace(/\\/g, '/') }) };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
55
101
  function hook() {
56
102
  const g = readInput();
57
103
  if (!g) process.exit(0);
@@ -70,6 +116,7 @@ function hook() {
70
116
  function checkAll() {
71
117
  const map = readMap();
72
118
  if (!map) { console.log('No structure map (.claude/structure.json); check skipped.'); return; }
119
+ if (map.broken) { console.error('.claude/structure.json does not parse (it must be JSON); the structure cannot be checked.'); process.exit(1); }
73
120
  const files = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean);
74
121
  const violations = files.map((f) => [f, check(f, map)]).filter(([, s]) => s);
75
122
  if (!violations.length) { console.log(`Structure OK: ${files.length} files.`); return; }
@@ -388,6 +388,12 @@
388
388
  "guard.destructive.pushMain": "Ana dala doğrudan push",
389
389
  "guard.destructive.rewriteHistory": "Git geçmişi yeniden yazılıyor",
390
390
  "guard.destructive.migration": "Migration uygulanıyor (hedef ortamı kontrol et)",
391
+ "guard.destructive.resourceDelete": "Bir küme kaynağı, deploy, alan adı ya da ortam değişkeni siliniyor",
392
+ "guard.destructive.publish": "Bir paket ya da sürüm yayımlanıyor (ya da yayından kaldırılıyor)",
393
+ "guard.destructive.branchDelete": "Ana dal siliniyor",
394
+ "guard.destructive.dockerPrune": "Kullanılmayan bütün Docker imajları siliniyor",
395
+ "guard.destructive.tooLong": "Komut kalkanın denetlemesi için fazla uzun ({length} karakter)",
396
+ "guard.destructive.tooLongWord": "Komut kalkanın denetlemesi için fazla uzun ({length} karakter) ve \"{word}\" içeriyor",
391
397
  "guard.destructive.remoteDb": "Komut localhost dışındaki bir veritabanına bağlanıyor (prod olabilir). Kullanıcı onayı gerekli; AI prod erişim bilgisi kullanmamalı.",
392
398
  "guard.destructive.testShell": "Test dosyası kabuk komutuyla değiştiriliyor, taşınıyor veya siliniyor. Testi geçirmek için testi zayıflatmak yasak (AGENTS.md kural 7); test düzenlemesini dosya aracıyla yap. Kullanıcı onayı gerekli.",
393
399
  "guard.destructive.inlineTest": "Satır içi betik bir test yolunu anıyor; test dosyasına yazıyor olabilir. Test dosyasına yalnız test-writer ajanı yazar (AGENTS.md kural 7). Kullanıcı onayı gerekli.",
@@ -446,8 +452,14 @@
446
452
  "guard.package.depFileUnclear": "Paket kapısı: {path} içindeki yeni bağımlılıklar okunamadı (dosya ayrıştırılamıyor ya da düzenleme yerleştirilemiyor). Kullanıcı onayı gerekli.",
447
453
  "guard.package.error": "Paket kapısı bu komutu denetleyemedi ({error}). Kullanıcı onayı gerekli.",
448
454
  "guard.package.ask": "Paket kapısı uyarısı: {list}. Kurmadan önce paketin gerçekten istenen paket olduğundan emin ol.",
455
+ "guard.package.notRegistry": "açık kayıt dışından ({source}); kapı bunu denetleyemez",
456
+ "guard.package.otherEco": "kapı {eco} kaydını denetlemez",
457
+ "guard.package.configFile": "Paket kapısı: {path} paketlerin nereden kurulacağını belirliyor (kayıt, dizin ya da kimlik bilgisi). Kullanıcı onayı gerekli.",
449
458
  "guard.structure.offMap": "\"{rel}\" yapı haritasında yok (.claude/structure.json). Doğru yer muhtemelen şunlardan biri: {top}. Geçici veya deneme ise sandbox/ altına yaz. Kalıcı yeni bir yer gerekiyorsa bunu teknik karar olarak sor ve haritayı güncelle (docs/architecture/structure.md).",
450
459
  "guard.structure.prefix": "Yapı: {reason}",
460
+ "guard.structure.longPath": "{length} karakterlik bir yol yapı haritasıyla denetlenmez (1024 üstü). Kullanıcı onayı gerekli.",
461
+ "guard.structure.brokenMap": ".claude/structure.json ayrıştırılamıyor, bu yüzden \"{rel}\" haritayla denetlenemiyor. Haritayı düzelt (JSON) ya da bu dosyayı onayla.",
462
+ "guard.structure.clutterShell": "Komut \"{rel}\" dosyasını oluşturuyor; bu bir dağınıklık desenine uyuyor (yedek, eski sürüm, kopya ya da geçici dosya). Git geçmişi zaten yedek; denemeler için sandbox/ kullan. Kullanıcı onayı gerekli.",
451
463
  "guard.test.skip": "test atlanıyor veya tek teste odaklanıyor (.skip / .only / skipIf / xit / pytest skip)",
452
464
  "guard.test.expectRemoved": "beklenti (expect/assert) siliniyor veya yoruma alınıyor",
453
465
  "guard.test.expectWeak": "beklenti gevşetiliyor (toBeTruthy, toBeDefined, expect.anything, matcher'sız expect, assert True)",
package/AGENTS.md CHANGED
@@ -37,7 +37,7 @@ The kit was written for Claude Code; these equivalents apply in these two tools:
37
37
  - **Wherever AskUserQuestion appears:** in Antigravity, the `ask_question` tool. If there is no question tool with options (Codex), write the options as a numbered list, put the recommended one first and mark it "(Recommended)", then wait for the answer. The decision is still the user's.
38
38
  - **Subagents** (board members, council, review agents in `.claude/agents/`): If you cannot run them, read the matching definition, assess as that role yourself and state "single model" in the report. The test separation (rule 7) cannot be enforced here by a hook: write the tests with the `test-writer` definition, before you think about the code, and show the red run; do not change the test file while writing the code.
39
39
  - **Model and effort** (`docs/workflow/models.md`): Start the subagent with the role's equivalent (Codex: model and reasoning effort in the prompt; Antigravity: `inherit`, `flash` or `pro`). If you cannot, run the role yourself and suggest the model in text.
40
- - **Protections** (secret block, destructive command shield, structure guard, edit lock, test protection, edit round counter, package gate and the equivalent of the Claude Code permission list) run as hooks: Codex `.codex/hooks.json`, Antigravity `.agents/hooks.json`. Writing to rule and protection files (`AGENTS.md`, `.claude/`, `.codex/`, `.agents/hooks.json`) needs approval. Do not try another route (for example a shell command) around a blocked action; tell the user why. Known limits: the commit gate (`commit-gate.mjs`, gate and board approval before a commit) is not wired in these tools, so before every commit run `node .claude/hooks/gate.mjs --run` and the board review yourself and commit only on GREEN; the shell rules read the command text and can miss unusual spellings.
40
+ - **Protections** (secret block, destructive command shield, structure guard, edit lock, test protection, edit round counter, package gate, commit gate and the equivalent of the Claude Code permission list) run as hooks: Codex `.codex/hooks.json`, Antigravity `.agents/hooks.json`. Writing to rule and protection files (`AGENTS.md`, `.claude/`, `.codex/`, `.agents/hooks.json`) needs approval. Do not try another route (for example a shell command) around a blocked action; tell the user why. The commit gate stops a commit until `node .claude/hooks/gate.mjs --run` is green and the board review (you, as the relevant members) is approved with `commit-gate.mjs --approve`; it does not check who wrote a test file there. Known limit: the shell rules read the command text and can miss unusual spellings.
41
41
  - Skills that belong to Claude Code plugins (named `plugin:skill`) do not exist in these tools: skip that step. There is also no limit reading and no status line: the limit stays "-" in the status block.
42
42
  <!-- /kit:other-tools -->
43
43
 
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="Version" src="https://img.shields.io/badge/version-3.49.11-2a78d6?style=flat-square"></a>
9
+ <a href="kit/CHANGELOG.md"><img alt="Version" src="https://img.shields.io/badge/version-3.50.0-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">
@@ -236,8 +236,8 @@ A new file outside the map is asked about, clutter like `*.bak` and `*-old.*` is
236
236
  | Guardrail | What it does |
237
237
  |---|---|
238
238
  | Secret guard | Blocks API keys written into code or docs |
239
- | Destructive-command shield | Blocks `DROP`, unconditional `DELETE`, `db reset`; asks before prod deploys and rollbacks |
240
- | Package gate | Blocks packages that don't exist (on install and on `npx`, `uvx` runs); asks about very new ones or lookalikes of popular ones |
239
+ | Destructive-command shield | Blocks `DROP`, unconditional `DELETE`, `db reset` and cloud data deletion; asks before prod deploys, rollbacks and publishing |
240
+ | Package gate | Blocks packages that don't exist (on install, on `npx`, `uvx` runs and in dependency files); asks about very new ones, lookalikes of popular ones and sources it can't check (git, URLs, other registries) |
241
241
  | Structure guard | Asks about files outside the map, blocks clutter files |
242
242
  | Test separation | Only the `test-writer` agent writes test files; the main session and the coding agent are blocked |
243
243
  | Test guard | Asks before a test is deleted, skipped or weakened |
@@ -277,7 +277,7 @@ Every command, setting, tool and skill is in the **[guide](https://burakdogrul.g
277
277
 
278
278
  ## Codex and Antigravity
279
279
 
280
- The kit is built for Claude Code; its rules, commands and guardrails also work in OpenAI Codex and Google Antigravity. There the board runs on a single model, and the question UI, status bar and commit gate are not available. These tools don't report which agent is running, so test separation stays a written rule there. Install with `node <kit>/kit/kit.mjs init --tool codex` (or `antigravity`).
280
+ The kit is built for Claude Code; its rules, commands and guardrails also work in OpenAI Codex and Google Antigravity. There the board runs on a single model, and the question UI and status bar are not available; the commit gate works there too. These tools don't report which agent is running, so test separation stays a written rule there. Install with `node <kit>/kit/kit.mjs init --tool codex` (or `antigravity`).
281
281
 
282
282
  ## Measured
283
283
 
package/README.tr.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.49.11-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.50.0-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">
@@ -236,8 +236,8 @@ Harita dışı yeni dosya sorulur, `*.bak` ve `*-old.*` gibi dağınıklık dosy
236
236
  | Katman | Ne yapar |
237
237
  |---|---|
238
238
  | Sır engeli | Koda veya dokümana yazılan API anahtarını engeller |
239
- | Yıkıcı komut kalkanı | `DROP`, koşulsuz `DELETE`, `db reset` engellenir; prod deploy ve geri alma sorulur |
240
- | Paket kapısı | Var olmayan paketi engeller (kurulumda ve `npx`, `uvx` ile çalıştırmada); çok yeni veya popüler bir pakete benzeyeni sorar |
239
+ | Yıkıcı komut kalkanı | `DROP`, koşulsuz `DELETE`, `db reset` ve bulut verisini silmek engellenir; prod deploy, geri alma ve yayımlama sorulur |
240
+ | Paket kapısı | Var olmayan paketi engeller (kurulumda, `npx`, `uvx` ile çalıştırmada ve bağımlılık dosyalarında); çok yeni, popüler bir pakete benzeyen ve denetleyemediği kaynakları (git, URL, başka kayıt) sorar |
241
241
  | Yapı koruyucu | Harita dışı dosyayı sorar, dağınıklık dosyalarını engeller |
242
242
  | Test ayrımı | Test dosyasına yalnız `test-writer` ajanı yazar; ana oturum ve kod ajanı denerse engellenir |
243
243
  | Test koruması | Silinen, atlanan veya gevşetilen testi sorar |
@@ -277,7 +277,7 @@ Tüm komutlar, ayarlar, araçlar ve skill'ler **[rehberde](https://burakdogrul.g
277
277
 
278
278
  ## Codex ve Antigravity
279
279
 
280
- Kit Claude Code için yazıldı; kurallar, komutlar ve korumalar OpenAI Codex ve Google Antigravity'de de çalışır. Kurul tek modelle yürür; soru arayüzü, durum çubuğu ve commit kapısı bu araçlarda yok. Bu araçlar ajan kimliği bildirmediği için test ayrımı orada kural metninde kalır. Kurulum: `node <kit>/kit/kit.mjs init --tool codex` (veya `antigravity`).
280
+ Kit Claude Code için yazıldı; kurallar, komutlar ve korumalar OpenAI Codex ve Google Antigravity'de de çalışır. Kurul tek modelle yürür; soru arayüzü ve durum çubuğu bu araçlarda yok, commit kapısı orada da çalışır. Bu araçlar ajan kimliği bildirmediği için test ayrımı orada kural metninde kalır. Kurulum: `node <kit>/kit/kit.mjs init --tool codex` (veya `antigravity`).
281
281
 
282
282
  ## Ölçüm
283
283
 
package/kit/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  The kit version is in `kit/VERSION`. To update a project, run `/kit-update` in it.
4
4
  Items with an "Adapt by hand" note concern files owned by the project; the update does not touch them.
5
5
 
6
+ ## [3.50.0] - 2026-10-30
7
+
8
+ ### Added
9
+ - **The commit gate runs on Codex and Antigravity.** A commit stops there too until the deterministic gate (`gate.mjs --run`) is green and the board review is approved with `commit-gate.mjs --approve`; a commit chained to other commands stops as well. The stop comes as the tool's own decision. Since these tools run one model, the AI reviews the diff as the relevant board members itself. `commit-gate.mjs --skip` and the gate command approval are asked on Antigravity and left to you on Codex (it has no approval prompt). Test separation stays a written rule there: these tools do not report which agent wrote a test file.
10
+
11
+ ### Fixed
12
+ - The cloud deletion rule of the shield no longer slows down on a long repeated command (`aws s3 rm` × 999: 24 ms).
13
+
14
+ ## [3.49.12] - 2026-10-30
15
+
16
+ ### Security
17
+ - **The package gate asks about installs it cannot check.** A git source, a URL or a GitHub shorthand (`npm i github:a/b`, `npm i user/repo`, `pip install git+https://…`, `npx github:a/b`) and a different registry or index (`--registry`, `--@scope:registry`, `--userconfig`, `pip install -i`/`--index-url`/`--extra-index-url`/`--find-links`, attached `-ihttps://…` too, an `npm_config_registry=` or `PIP_INDEX_URL=` set anywhere in the command, `npm config set registry` and `pip config set …index-url` in any spelling, `poetry source add`, a shell write to `.npmrc`, `.yarnrc.yml`, `pip.conf`, `uv.toml`, `poetry.toml`, `.pypirc` or `bunfig.toml`) are asked instead of passing unchecked. Writing one of those registry config files with a file tool is asked too. Local paths (`./lib`, `~/x`, `file:`, `-e .`) stay free.
18
+ - `cargo add`/`install`, `go get`/`install`, `gem install`, `bundle add` and `composer require` are asked: the gate does not query those registries. Local installs (`cargo install --path .`, `go install ./cmd/x`) stay free.
19
+ - **Dependency files:** in `package.json` a dependency whose source changes to git, a URL or `user/repo` is asked, and names added under `overrides`, `resolutions` and `pnpm.overrides` are checked. `pyproject.toml` also reads `[project.optional-dependencies]`, `[dependency-groups]`, `[tool.uv] dev-dependencies`, `[build-system] requires`, PDM and Hatch dependency lists, dotted keys and Poetry sub-tables; a quoted `]` no longer ends the list early; a direct reference (`pkg @ git+https://…`), a Poetry `git`/`url` table, a `[tool.uv.sources]` git source and a custom index (`[[tool.uv.index]]`) are asked. In `requirements*.txt` a git or URL line, an index option and a remote `-r` file are asked.
20
+ - **More cloud deletions are blocked:** `aws s3 rm --recursive`, `aws s3 rb --force`, `aws s3api delete-bucket`, `aws rds delete-db-instance`/`delete-db-cluster`, `aws dynamodb delete-table`, `gcloud projects delete`, `az resource delete`, `aws ec2 terminate-instances`, `gcloud sql`/`compute instances delete`, `gsutil rm -r`, `az group delete`, `heroku apps:destroy`, `fly apps`/`volumes destroy`, `supabase projects delete`, `netlify sites:delete` and `gh repo delete`, also with options before or after the service (`aws --profile x s3 --region r …`, `heroku -a x …`).
21
+ - **More deletions and publishes ask you:** `kubectl delete`, `helm uninstall`, `railway down`, `vercel remove`/`env rm`/`domains rm`, deleting the main branch (`git branch -D main`), `npm`/`pnpm`/`yarn publish` and `unpublish`, with workspace flags too (not `--dry-run`), `npm deprecate`/`dist-tag`, `cargo publish`, `twine upload`, `lerna`/`changeset publish`, `gh release create`/`delete`, `docker system prune -a`; options before the subcommand (`helm -n ns uninstall`) and `.exe`/`.cmd` tool names do not hide them.
22
+ - **Clutter files made from the shell are asked:** `cp a.ts a.bak`, `mv a.ts a-old.ts`, `echo x > notes-old.md`, `tee a.bak`, `Copy-Item a.ts a.bak` meet the same clutter list as the file tools. They are asked rather than blocked, since the shell text is read by heuristics; quoted text, heredocs and arrows (`=>`) are not taken for file names.
23
+ - A shell command over 10 000 characters is not matched as a whole (some shield rules are not linear in the length, and a slow hook holds up the session): its short lines are still checked, so a blocked command stays blocked; otherwise it is asked, naming a guarded word it contains.
24
+ - A `.claude/structure.json` that does not parse no longer switches the structure guard off: new files are asked and `--check` fails. A path over 1024 characters is asked instead of matched.
25
+ - **The secret guard sees keys split across edits:** a key that only appears once a MultiEdit's (or an Antigravity chunk edit's) pieces land in the file is blocked. On Antigravity every tool's arguments are scanned, so a write tool the guard does not know is covered; on Codex a patch passed in `input` instead of `command` is read.
26
+
6
27
  ## [3.49.11] - 2026-10-30
7
28
 
8
29
  ### Security
package/kit/VERSION CHANGED
@@ -1 +1 @@
1
- 3.49.11
1
+ 3.50.0
package/kit/guide.html CHANGED
@@ -361,7 +361,7 @@ footer .container { display: flex; flex-wrap: wrap; gap: 16px; justify-content:
361
361
  <section class="hero">
362
362
  <div class="container">
363
363
  <div>
364
- <span class="pill"><b>v3.49.11</b> Starter kit for Claude Code · MIT</span>
364
+ <span class="pill"><b>v3.50.0</b> Starter kit for Claude Code · MIT</span>
365
365
  <h1>The AI builds.<br><em>You steer.</em></h1>
366
366
  <p class="sub">TillerHand takes a product from idea to production and maintenance with Claude Code. At every important decision the AI gives you the options, its recommendation and the cost of each option. An expert board reviews the work, and guardrails stop mistakes before they happen.</p>
367
367
  <div class="setup">
@@ -941,9 +941,9 @@ npx tillerhand init
941
941
  <thead><tr><th>Layer</th><th>What it does</th></tr></thead>
942
942
  <tbody>
943
943
  <tr><td>Secret block</td><td>Blocks API keys (Anthropic, OpenAI, Stripe, AWS, GitHub, Supabase…) written to code or docs; also scans before a commit</td></tr>
944
- <tr><td>Destructive command shield</td><td><code>DROP</code>, <code>TRUNCATE</code>, unconditional <code>DELETE</code>, <code>db reset</code> and pulling prod environment variables onto the machine are blocked; prod deploy, rollback, store submission and migrations are asked</td></tr>
945
- <tr><td>Package gate</td><td>A package that is installed, or downloaded and run with <code>npx</code> or <code>uvx</code>, is checked against the registry: if it doesn't exist it is blocked; if it is very new, rarely downloaded or looks like a popular package, it is asked</td></tr>
946
- <tr><td>Structure guard</td><td>A new file outside the map is asked; clutter such as <code>*.bak</code>, <code>*-old.*</code>, copies and <code>temp/</code> is blocked</td></tr>
944
+ <tr><td>Destructive command shield</td><td><code>DROP</code>, <code>TRUNCATE</code>, unconditional <code>DELETE</code>, <code>db reset</code> pulling prod environment variables onto the machine and deleting cloud data (S3 buckets, databases, resource groups, apps, repositories) are blocked; prod deploy, rollback, store submission, migrations, cluster and hosting deletes, package and release publishing and deleting the main branch are asked</td></tr>
945
+ <tr><td>Package gate</td><td>A package that is installed, or downloaded and run with <code>npx</code> or <code>uvx</code>, is checked against the registry: if it doesn't exist it is blocked; if it is very new, rarely downloaded or looks like a popular package, it is asked. A git or URL source, another registry or index, and cargo, go, gem and composer installs are asked, since they cannot be checked. Dependencies the AI adds to <code>package.json</code>, <code>requirements.txt</code> or <code>pyproject.toml</code> go through the same check</td></tr>
946
+ <tr><td>Structure guard</td><td>A new file outside the map is asked; clutter such as <code>*.bak</code>, <code>*-old.*</code>, copies and <code>temp/</code> is blocked; made from the shell (<code>cp a.ts a.bak</code>) it is asked</td></tr>
947
947
  <tr><td>Config approval</td><td><code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.claude/</code>, <code>.codex/</code>, <code>.mcp.json</code> and workflow files don't change without your approval</td></tr>
948
948
  <tr><td>Test separation</td><td>The tests are written before the code by a separate agent (<code>test-writer</code>, a different model is recommended), not by whoever writes the code. Nobody but this agent can write to a test file: the main session and the code agent are blocked if they try. A test file changed indirectly by a patch or script also stops at the commit gate. So whoever writes the code can't shape the test to fit their own code</td></tr>
949
949
  <tr><td>Test protection</td><td>Deleting or commenting out an assertion, <code>.skip</code>/<code>.only</code>, a loosened assertion, deleting or moving a test file, and narrowing coverage in the test config are asked; so is writing to a test file through the shell</td></tr>
@@ -1137,7 +1137,7 @@ Next: G-013 password reset · pending decision: email service</pre>
1137
1137
  <section id="tools">
1138
1138
  <p class="path">With another tool</p>
1139
1139
  <h2>Codex and Antigravity</h2>
1140
- <p class="lead">TillerHand was written for Claude Code. The rules, commands and guardrails also work in OpenAI Codex and Google Antigravity. The board runs on a single model; the question UI, the status line and the commit gate are not available in these tools.</p>
1140
+ <p class="lead">TillerHand was written for Claude Code. The rules, commands and guardrails also work in OpenAI Codex and Google Antigravity. The board runs on a single model; the question UI and the status line are not available in these tools. The commit gate works there too.</p>
1141
1141
  <pre class="term">npx tillerhand init --tool codex <span class="c"># or antigravity, or claude,codex</span>
1142
1142
  <span class="c"># add a tool to an existing project</span>
1143
1143
  /kit-update --tool claude,codex</pre>
@@ -1151,7 +1151,8 @@ Next: G-013 password reset · pending decision: email service</pre>
1151
1151
  <tr><td>Questions with options</td><td>AskUserQuestion</td><td>Numbered plain text</td><td>ask_question</td></tr>
1152
1152
  <tr><td>Board and council</td><td>Separate agents and models</td><td>One model takes the roles in turn</td><td>One model</td></tr>
1153
1153
  <tr><td>Test separation</td><td>Enforced by a hook: only test-writer writes to a test file</td><td>A written rule (no agent identity)</td><td>A written rule (no agent identity)</td></tr>
1154
- <tr><td>Status line, limit, dashboard refresh, commit gate</td><td>Yes</td><td>No</td><td>No</td></tr>
1154
+ <tr><td>Status line, limit, dashboard refresh</td><td>Yes</td><td>No</td><td>No</td></tr>
1155
+ <tr><td>Commit gate</td><td>Yes</td><td>Yes; a stop is a block, the AI reviews as the board itself</td><td>Yes; the AI reviews as the board itself</td></tr>
1155
1156
  </tbody>
1156
1157
  </table></div>
1157
1158
  <div class="note">The commands have a single source (<code>.claude/commands/</code>); the copies under <code>.agents/skills/</code> are generated by <code>init</code>, <code>install</code> and <code>update</code>. Codex runs the project's hooks only when you trust the folder and approve them with <code>/hooks</code>; do this in the first session. On first use, try writing a secret to see the guardrails work.</div>
@@ -1241,7 +1242,7 @@ Next: G-013 password reset · pending decision: email service</pre>
1241
1242
  <footer>
1242
1243
  <div class="container">
1243
1244
  <a class="brand" href="#top" style="font-size:16px"><svg class="mark" style="width:22px;height:22px"><use href="#mark"/></svg>TillerHand</a>
1244
- <span>Version 3.49.11 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT license</a> · The KVKK section is a checklist, not legal advice.</span>
1245
+ <span>Version 3.50.0 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT license</a> · The KVKK section is a checklist, not legal advice.</span>
1245
1246
  </div>
1246
1247
  </footer>
1247
1248
 
package/kit/guide.tr.html CHANGED
@@ -361,7 +361,7 @@ footer .container { display: flex; flex-wrap: wrap; gap: 16px; justify-content:
361
361
  <section class="hero">
362
362
  <div class="container">
363
363
  <div>
364
- <span class="pill"><b>v3.49.11</b> Claude Code için başlangıç kiti · MIT</span>
364
+ <span class="pill"><b>v3.50.0</b> Claude Code için başlangıç kiti · MIT</span>
365
365
  <h1>Kodu AI yazar.<br><em>Dümen sende.</em></h1>
366
366
  <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>
367
367
  <div class="setup">
@@ -944,9 +944,9 @@ npx tillerhand init
944
944
  <thead><tr><th>Katman</th><th>Ne yapar</th></tr></thead>
945
945
  <tbody>
946
946
  <tr><td>Sır engeli</td><td>Koda veya dokümana yazılan API anahtarını (Anthropic, OpenAI, Stripe, AWS, GitHub, Supabase…) engeller; commit öncesi de tarar</td></tr>
947
- <tr><td>Yıkıcı komut kalkanı</td><td><code>DROP</code>, <code>TRUNCATE</code>, koşulsuz <code>DELETE</code>, <code>db reset</code> ve prod ortam değişkenlerini makineye çekmek engellenir; prod deploy, geri alma, mağazaya gönderim ve migration sorulur</td></tr>
948
- <tr><td>Paket kapısı</td><td>Kurulan ya da <code>npx</code>, <code>uvx</code> ile indirilip çalıştırılan paket kayıt defterinden doğrulanır: yoksa engellenir; çok yeni, az indirilmiş veya popüler bir pakete benziyorsa sorulur</td></tr>
949
- <tr><td>Yapı koruyucu</td><td>Harita dışı yeni dosya sorulur; <code>*.bak</code>, <code>*-old.*</code>, kopya ve <code>temp/</code> gibi dağınıklıklar engellenir</td></tr>
947
+ <tr><td>Yıkıcı komut kalkanı</td><td><code>DROP</code>, <code>TRUNCATE</code>, koşulsuz <code>DELETE</code>, <code>db reset</code> , prod ortam değişkenlerini makineye çekmek ve bulut verisini silmek (S3 kovası, veritabanı, kaynak grubu, uygulama, depo) engellenir; prod deploy, geri alma, mağazaya gönderim, migration, küme ve barındırma silmeleri, paket ve sürüm yayımlama ve ana dalı silmek sorulur</td></tr>
948
+ <tr><td>Paket kapısı</td><td>Kurulan ya da <code>npx</code>, <code>uvx</code> ile indirilip çalıştırılan paket kayıt defterinden doğrulanır: yoksa engellenir; çok yeni, az indirilmiş veya popüler bir pakete benziyorsa sorulur. Git ya da URL kaynağı, başka bir kayıt ya da dizin ve cargo, go, gem, composer kurulumları denetlenemediği için sorulur. AI'nın <code>package.json</code>, <code>requirements.txt</code> ya da <code>pyproject.toml</code>'a eklediği bağımlılıklar da aynı denetimden geçer</td></tr>
949
+ <tr><td>Yapı koruyucu</td><td>Harita dışı yeni dosya sorulur; <code>*.bak</code>, <code>*-old.*</code>, kopya ve <code>temp/</code> gibi dağınıklıklar engellenir; kabukta oluşturulursa (<code>cp a.ts a.bak</code>) sorulur</td></tr>
950
950
  <tr><td>Yapılandırma onayı</td><td><code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.claude/</code>, <code>.codex/</code>, <code>.mcp.json</code> ve iş akışı dosyaları onayın olmadan değişmez</td></tr>
951
951
  <tr><td>Test ayrımı</td><td>Testleri kodu yazan değil, ayrı bir ajan (<code>test-writer</code>, farklı model önerilir) koddan önce yazar. Test dosyasına bu ajan dışında kimse yazamaz: ana oturum ve kod ajanı denerse engellenir. Patch veya betikle dolaylı değişen test dosyası da commit kapısında durur. Böylece kodu yazan testi kendi koduna göre şekillendiremez</td></tr>
952
952
  <tr><td>Test koruması</td><td>Beklenti silmek veya yoruma almak, <code>.skip</code>/<code>.only</code>, gevşek beklenti, test dosyasını silmek veya taşımak ve test ayarında kapsamı daraltmak sorulur; kabukla test dosyasına yazmak da</td></tr>
@@ -1141,7 +1141,7 @@ Sıradaki: G-013 şifre sıfırlama · bekleyen karar: e-posta servisi</pre>
1141
1141
  <section id="araclar">
1142
1142
  <p class="path">Başka araçla</p>
1143
1143
  <h2>Codex ve Antigravity</h2>
1144
- <p class="lead">TillerHand Claude Code için yazıldı. Kurallar, komutlar ve korumalar OpenAI Codex ve Google Antigravity'de de çalışır. Kurul tek modelle yürür; soru arayüzü, durum çubuğu ve commit kapısı bu araçlarda yok.</p>
1144
+ <p class="lead">TillerHand Claude Code için yazıldı. Kurallar, komutlar ve korumalar OpenAI Codex ve Google Antigravity'de de çalışır. Kurul tek modelle yürür; soru arayüzü ve durum çubuğu bu araçlarda yok. Commit kapısı orada da çalışır.</p>
1145
1145
  <pre class="term">npx tillerhand init --tool codex <span class="c"># ya da antigravity, ya da claude,codex</span>
1146
1146
  <span class="c"># var olan projeye araç eklemek</span>
1147
1147
  /kit-update --tool claude,codex</pre>
@@ -1155,7 +1155,8 @@ Sıradaki: G-013 şifre sıfırlama · bekleyen karar: e-posta servisi</pre>
1155
1155
  <tr><td>Seçenekli sorular</td><td>AskUserQuestion</td><td>Numaralı düz metin</td><td>ask_question</td></tr>
1156
1156
  <tr><td>Kurul ve konsey</td><td>Ayrı ajanlar ve modeller</td><td>Tek model rolleri sırayla üstlenir</td><td>Tek model</td></tr>
1157
1157
  <tr><td>Test ayrımı</td><td>Hook zorlar: test dosyasına yalnız test-writer yazar</td><td>Kural metinde (ajan kimliği yok)</td><td>Kural metinde (ajan kimliği yok)</td></tr>
1158
- <tr><td>Durum çubuğu, limit, pano yenileme, commit kapısı</td><td>Var</td><td>Yok</td><td>Yok</td></tr>
1158
+ <tr><td>Durum çubuğu, limit, pano yenileme</td><td>Var</td><td>Yok</td><td>Yok</td></tr>
1159
+ <tr><td>Commit kapısı</td><td>Var</td><td>Var; durdurma engel olarak gelir, AI kurul rollerini kendisi üstlenir</td><td>Var; AI kurul rollerini kendisi üstlenir</td></tr>
1159
1160
  </tbody>
1160
1161
  </table></div>
1161
1162
  <div class="note">Komutların kaynağı tektir (<code>.claude/commands/</code>); <code>.agents/skills/</code> altındaki kopyaları <code>init</code>, <code>install</code> ve <code>update</code> üretir. Codex proje hook'larını sadece klasöre güvendiğinde ve <code>/hooks</code> ile onayladığında çalıştırır; bunu ilk oturumda yap. İlk kullanımda bir sır yazma denemesiyle korumaların çalıştığını gör.</div>
@@ -1245,7 +1246,7 @@ Sıradaki: G-013 şifre sıfırlama · bekleyen karar: e-posta servisi</pre>
1245
1246
  <footer>
1246
1247
  <div class="container">
1247
1248
  <a class="brand" href="#top" style="font-size:16px"><svg class="mark" style="width:22px;height:22px"><use href="#mark"/></svg>TillerHand</a>
1248
- <span>Sürüm 3.49.11 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT lisansı</a> · KVKK bölümü kontrol listesidir, hukuki görüş değildir.</span>
1249
+ <span>Sürüm 3.50.0 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT lisansı</a> · KVKK bölümü kontrol listesidir, hukuki görüş değildir.</span>
1249
1250
  </div>
1250
1251
  </footer>
1251
1252
 
@@ -48,7 +48,8 @@
48
48
  "hooks": [
49
49
  { "type": "command", "command": "node .claude/hooks/guard-secrets.mjs" },
50
50
  { "type": "command", "command": "node .claude/hooks/destructive-guard.mjs" },
51
- { "type": "command", "command": "node .claude/hooks/package-gate.mjs", "timeout": 30 }
51
+ { "type": "command", "command": "node .claude/hooks/package-gate.mjs", "timeout": 30 },
52
+ { "type": "command", "command": "node .claude/hooks/commit-gate.mjs" }
52
53
  ]
53
54
  }
54
55
  ]
@@ -24,7 +24,8 @@
24
24
  "hooks": [
25
25
  { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/guard-secrets.mjs\"", "commandWindows": "node .claude/hooks/guard-secrets.mjs" },
26
26
  { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/destructive-guard.mjs\"", "commandWindows": "node .claude/hooks/destructive-guard.mjs" },
27
- { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/package-gate.mjs\"", "commandWindows": "node .claude/hooks/package-gate.mjs", "timeout": 30 }
27
+ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/package-gate.mjs\"", "commandWindows": "node .claude/hooks/package-gate.mjs", "timeout": 30 },
28
+ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/commit-gate.mjs\"", "commandWindows": "node .claude/hooks/commit-gate.mjs" }
28
29
  ]
29
30
  }
30
31
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tillerhand",
3
- "version": "3.49.11",
3
+ "version": "3.50.0",
4
4
  "description": "Vibe coding kit for Claude Code, Codex and Antigravity: rules, commands, guards and project templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",