tillerhand 3.48.0 → 3.49.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.
Files changed (119) hide show
  1. package/.claude/agents/board-chair.md +1 -1
  2. package/.claude/agents/security-auditor.md +1 -1
  3. package/.claude/agents/test-writer.md +1 -1
  4. package/.claude/board/board-product.md +1 -1
  5. package/.claude/board/council.md +1 -1
  6. package/.claude/board/rules.md +6 -6
  7. package/.claude/commands/adopt.md +3 -3
  8. package/.claude/commands/ask-mode.md +4 -4
  9. package/.claude/commands/board.md +1 -1
  10. package/.claude/commands/build.md +1 -1
  11. package/.claude/commands/design.md +4 -4
  12. package/.claude/commands/findings-business.md +1 -1
  13. package/.claude/commands/findings-legal.md +1 -1
  14. package/.claude/commands/findings-perf.md +1 -1
  15. package/.claude/commands/findings-security.md +2 -2
  16. package/.claude/commands/findings-tech.md +1 -1
  17. package/.claude/commands/findings-ux.md +1 -1
  18. package/.claude/commands/fix-bug.md +2 -2
  19. package/.claude/commands/handoff.md +2 -2
  20. package/.claude/commands/kit-update.md +2 -2
  21. package/.claude/commands/qa.md +1 -1
  22. package/.claude/commands/release.md +2 -2
  23. package/.claude/commands/research.md +3 -3
  24. package/.claude/commands/retro.md +1 -1
  25. package/.claude/commands/rollback.md +3 -3
  26. package/.claude/commands/start.md +4 -4
  27. package/.claude/commands/tools.md +1 -1
  28. package/.claude/commands/user-feedback.md +1 -1
  29. package/.claude/findings/lenses/business.md +1 -1
  30. package/.claude/findings/protocol.md +2 -2
  31. package/.claude/hooks/budget.mjs +1 -1
  32. package/.claude/hooks/checkpoint.mjs +2 -2
  33. package/.claude/hooks/commit-gate.mjs +14 -15
  34. package/.claude/hooks/council.mjs +37 -52
  35. package/.claude/hooks/dashboard-data.mjs +34 -42
  36. package/.claude/hooks/dashboard.mjs +18 -20
  37. package/.claude/hooks/destructive-guard.mjs +2 -2
  38. package/.claude/hooks/flow.mjs +1 -1
  39. package/.claude/hooks/freeze-guard.mjs +2 -2
  40. package/.claude/hooks/gate.mjs +18 -11
  41. package/.claude/hooks/labels.mjs +92 -152
  42. package/.claude/hooks/lang.mjs +25 -9
  43. package/.claude/hooks/messages.mjs +496 -494
  44. package/.claude/hooks/notify.mjs +1 -1
  45. package/.claude/hooks/prefs.mjs +10 -18
  46. package/.claude/hooks/preview.mjs +1 -1
  47. package/.claude/hooks/progress.mjs +7 -9
  48. package/.claude/hooks/qa-report.mjs +1 -3
  49. package/.claude/hooks/session-start.mjs +2 -3
  50. package/.claude/hooks/status-read.mjs +5 -5
  51. package/.claude/hooks/statusline.mjs +4 -4
  52. package/.claude/hooks/tools.mjs +3 -7
  53. package/.claude/hooks/usage.mjs +2 -2
  54. package/.claude/i18n/tr.json +495 -0
  55. package/.claude/security-patterns.json +1 -1
  56. package/.claude/skills/logo-design/SKILL.md +2 -2
  57. package/.claude/skills/motion-video/SKILL.md +1 -1
  58. package/.claude/skills/source-research/SKILL.md +1 -1
  59. package/.claude/skills/ui-motion/SKILL.md +1 -1
  60. package/AGENTS.md +1 -1
  61. package/CHANGELOG.md +1 -1
  62. package/README.md +2 -2
  63. package/README.tr.md +2 -2
  64. package/docs/architecture/overview.md +1 -1
  65. package/docs/decisions/log.md +1 -1
  66. package/docs/legal/privacy-kvkk.md +35 -35
  67. package/docs/operations/deploy-setup.md +1 -1
  68. package/docs/operations/feature-flags.md +1 -1
  69. package/docs/product/glossary.md +3 -3
  70. package/docs/product/launch.md +1 -1
  71. package/docs/product/prd.md +2 -2
  72. package/docs/product/roadmap.md +1 -1
  73. package/docs/product/specs/_template.md +1 -1
  74. package/docs/product/unit-economics.md +1 -1
  75. package/docs/quality/test-plan.md +5 -5
  76. package/docs/workflow/decision-protocol.md +1 -2
  77. package/docs/workflow/mcp-catalog.md +1 -1
  78. package/docs/workflow/skills.md +2 -2
  79. package/docs/workflow/usage-limits.md +1 -1
  80. package/kit/CHANGELOG.md +37 -0
  81. package/kit/VERSION +1 -1
  82. package/kit/guide.html +56 -56
  83. package/kit/guide.tr.html +10 -10
  84. package/kit/kit.mjs +7 -42
  85. package/kit/templates/addons/ai/.env.example.append +3 -3
  86. package/kit/templates/addons/ai/ai/evals/README.md +5 -5
  87. package/kit/templates/addons/ai/ai/evals/cases.jsonl +3 -3
  88. package/kit/templates/addons/ai/docs/ai/guide.md +1 -1
  89. package/kit/templates/addons/tr/docs/legal/turkiye.md +55 -55
  90. package/kit/templates/addons/tr/types/backend/.env.example.append +2 -2
  91. package/kit/templates/addons/tr/types/backend/app/shared/payments.py +1 -1
  92. package/kit/templates/addons/tr/types/backend/tests/security/test_payment_webhook.py +3 -3
  93. package/kit/templates/addons/tr/types/web-saas/.env.example.append +2 -2
  94. package/kit/templates/addons/tr/types/web-saas/.github/workflows/payment-webhook.yml +3 -3
  95. package/kit/templates/addons/tr/types/web-saas/src/lib/payments/webhook-signature.ts +1 -1
  96. package/kit/templates/addons/tr/types/web-saas/tests/security/payment-webhook.mjs +4 -4
  97. package/kit/templates/backend/.github/dependabot.yml +1 -1
  98. package/kit/templates/backend/.github/workflows/ci.yml +4 -4
  99. package/kit/templates/backend/.github/workflows/claude-ci-fix.yml +10 -10
  100. package/kit/templates/backend/.github/workflows/claude-maintenance.yml +1 -1
  101. package/kit/templates/backend/.github/workflows/live-monitoring.yml +2 -2
  102. package/kit/templates/backend/.pre-commit-config.yaml +1 -1
  103. package/kit/templates/backend/docs/architecture/stack.md +3 -3
  104. package/kit/templates/mobile/.github/dependabot.yml +1 -1
  105. package/kit/templates/mobile/.github/workflows/ci.yml +6 -6
  106. package/kit/templates/mobile/.github/workflows/claude-ci-fix.yml +10 -10
  107. package/kit/templates/mobile/.github/workflows/claude-maintenance.yml +1 -1
  108. package/kit/templates/mobile/docs/architecture/stack.md +4 -4
  109. package/kit/templates/mobile/docs/architecture/structure.md +1 -1
  110. package/kit/templates/mobile/docs/design/design-guide.md +1 -1
  111. package/kit/templates/web-saas/.github/dependabot.yml +1 -1
  112. package/kit/templates/web-saas/.github/workflows/ci.yml +6 -6
  113. package/kit/templates/web-saas/.github/workflows/claude-ci-fix.yml +10 -10
  114. package/kit/templates/web-saas/.github/workflows/claude-maintenance.yml +1 -1
  115. package/kit/templates/web-saas/.github/workflows/live-monitoring.yml +2 -2
  116. package/kit/templates/web-saas/docs/architecture/stack.md +4 -4
  117. package/kit/templates/web-saas/docs/design/design-guide.md +1 -1
  118. package/kit/tools/antigravity/.agents/hooks.json +5 -5
  119. package/package.json +1 -1
@@ -3,7 +3,7 @@
3
3
  // PreToolUse (Bash) hook: without review approval it stops the commit with exit code 2 and says what to do.
4
4
  // node .claude/hooks/commit-gate.mjs --approve "<members who took part, or a note>" binds the approval to the current changes.
5
5
  // node .claude/hooks/commit-gate.mjs --skip "<reason>" only when the user asks: skips the board and the gate (always asked).
6
- // Exempt: no board set up, "Commit review: off" (or the old "Commit denetimi: kapalı") in docs/workflow/board.md, or only docs changed.
6
+ // Exempt: no board set up, "Commit review: off" in docs/workflow/board.md, or only docs changed.
7
7
  // Commits the user makes from their own terminal are not affected (this is a Claude Code hook).
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
@@ -11,7 +11,6 @@ import crypto from 'node:crypto';
11
11
  import { execSync } from 'node:child_process';
12
12
  import { findRoot, isMain } from './shared.mjs';
13
13
  import { t } from './lang.mjs';
14
- import { normalize } from './labels.mjs';
15
14
  import { unquote } from './destructive-guard.mjs';
16
15
  import { isTestFile, isTestConfig, digest, readRecord } from './test-guard.mjs';
17
16
  import { isCodeFile } from './edit-rounds.mjs';
@@ -22,7 +21,7 @@ const approvalFile = path.join(root, '.claude', 'state', 'commit-approval.json')
22
21
  const git = (c) => execSync(`git ${c}`, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
23
22
 
24
23
  // Changes that will go into the commit: staged, unstaged in tracked files (git commit -a) and untracked new
25
- // files (git add -A && git commit). -z: Turkish or spaced paths are not quoted and escaped
24
+ // files (git add -A && git commit). -z: non-ASCII or spaced paths are not quoted and escaped
26
25
  // The signature is each changed file's content on disk, so "git add" of the approved content keeps the approval and the gate.
27
26
  // The commit takes the index: a file staged with other content than the disk (partial staging) also adds its index blob.
28
27
  // --no-renames: a staged rename must list the same two paths as the unstaged delete + new file.
@@ -43,8 +42,8 @@ export function changes() {
43
42
  h.update(`\0${f}\0`);
44
43
  let body = null;
45
44
  try { body = fs.readFileSync(path.join(root, f)); } catch { /* deleted */ }
46
- h.update(body ?? '\0silindi');
47
- hashes.set(f, body ? digest(body) : 'silindi');
45
+ h.update(body ?? '\0deleted');
46
+ hashes.set(f, body ? digest(body) : 'deleted');
48
47
  }
49
48
  for (const f of partial) {
50
49
  h.update(`\0index:${f}:${blobs.get(f)}`);
@@ -86,7 +85,7 @@ export function commitPattern(command) {
86
85
  // Allowed: commit alone, with "git add" and "cd" before it. A heredoc and a $(cat <<'EOF' … EOF) message do not count as commands.
87
86
  export function chainIssue(command, commit = COMMIT) {
88
87
  const k = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '<<HEREDOC')
89
- .replace(/\$\(\s*cat\s+<<HEREDOC\s*\)/g, 'MESAJ')
88
+ .replace(/\$\(\s*cat\s+<<HEREDOC\s*\)/g, 'MESSAGE')
90
89
  .replace(/'[^']*'/g, "''")
91
90
  .replace(/"((?:[^"\\]|\\.)*)"/g, (m, inner) => (/\$\(|`/.test(inner) ? m : '""'))
92
91
  .replace(/\d*>&\d+/g, '');
@@ -99,26 +98,26 @@ export const docsOnly = (files) => files.every((f) => /\.(md|txt)$/i.test(f) ||
99
98
 
100
99
  function hasBoard() {
101
100
  const agents = path.join(root, '.claude', 'agents');
102
- // board-chair (council chair) ships with the template and is not a board member; member files are copied in the /start Kurul step
101
+ // board-chair (council chair) ships with the template and is not a board member; member files are copied in the /start board step
103
102
  return fs.existsSync(agents) && fs.readdirSync(agents).some((f) => /^board-.+\.md$/.test(f) && f !== 'board-chair.md');
104
103
  }
105
104
 
106
105
  function reviewOff() {
107
106
  try {
108
107
  // The whole word: "**Commit review:** offer" does not switch the review off
109
- return /^\*\*Commit review:\*\*[ \t]*(off|kapalı)(?![\p{L}\p{N}])/imu.test(normalize(fs.readFileSync(path.join(root, 'docs', 'workflow', 'board.md'), 'utf8')));
108
+ return /^\*\*Commit review:\*\*[ \t]*off(?![\p{L}\p{N}])/imu.test(fs.readFileSync(path.join(root, 'docs', 'workflow', 'board.md'), 'utf8'));
110
109
  } catch {
111
110
  return false;
112
111
  }
113
112
  }
114
113
 
115
- // A note starting with "skipped" ("atlandı" before 3.48) skips the board and the gate. --approve refuses both whatever
116
- // the spelling ($'atland\xc4\xb1', $(printf ...)); only --skip writes it, and --skip is always asked.
117
- const SKIPPED = /^(skipped|atlandı)/i;
114
+ // A note starting with "skipped" skips the board and the gate. --approve refuses it in any case;
115
+ // only --skip writes it, and --skip is always asked.
116
+ const SKIPPED = /^skipped/i;
118
117
 
119
118
  function approve(note) {
120
119
  if (SKIPPED.test(note)) {
121
- console.error('A note starting with "skipped" or "atlandı" is not written with --approve. If the user explicitly asks: node .claude/hooks/commit-gate.mjs --skip "<reason>"');
120
+ console.error('A note starting with "skipped" is not written with --approve. If the user explicitly asks: node .claude/hooks/commit-gate.mjs --skip "<reason>"');
122
121
  process.exit(1);
123
122
  }
124
123
  save(note);
@@ -238,7 +237,7 @@ async function hook() {
238
237
  // written by the test runner, not by Write/Edit. A partially staged test (git apply --cached, skip-worktree) commits an
239
238
  // index blob the record does not describe, so it always stops.
240
239
  const written = readRecord(root);
241
- const foreign = d.files.filter((f) => isTestFile(f) && isCodeFile(f) && !isTestConfig(f) && d.hashes[f] !== 'silindi' && written[f] !== d.hashes[f]);
240
+ const foreign = d.files.filter((f) => isTestFile(f) && isCodeFile(f) && !isTestConfig(f) && d.hashes[f] !== 'deleted' && written[f] !== d.hashes[f]);
242
241
  if (foreign.length && !(skipped && approval.signature === d.signature)) {
243
242
  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` +
244
243
  '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' +
@@ -268,8 +267,8 @@ async function hook() {
268
267
  : `Commit stopped: the board has not reviewed these changes (${d.files.length} files).\n`) +
269
268
  '0. Run the deterministic gate (lint, types, tests, Semgrep) with node .claude/hooks/gate.mjs --run; give its output to the members.\n' +
270
269
  '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' +
271
- '2. If there is no RET: node .claude/hooks/commit-gate.mjs --approve "<members who took part>" and try the commit again.\n' +
272
- '3. If there is a RET, do not commit; ask the user about the finding and the options.\n' +
270
+ '2. If there is no REJECT: node .claude/hooks/commit-gate.mjs --approve "<members who took part>" and try the commit again.\n' +
271
+ '3. If there is a REJECT, do not commit; ask the user about the finding and the options.\n' +
273
272
  'If the user explicitly wants to skip the review for this commit, use --skip "user request".\n'
274
273
  );
275
274
  process.exit(2);
@@ -1,59 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  // Council tally (the stage-2 aggregate of Karpathy's llm-council): counts the anonymous rankings and finding votes in the minutes
3
3
  // and writes the "## 3. Overall ranking" section into them. Protocol: .claude/board/council.md
4
- // Minutes are read in English and Turkish (pre-3.48); the section is written in the language of the minutes.
5
- // node .claude/hooks/council.mjs docs/workflow/council/<tutanak>.md
4
+ // node .claude/hooks/council.mjs docs/workflow/council/<minutes>.md
6
5
  // The tally is done in code: a model cannot add up the ranking wrong, the chair cannot change the count.
7
6
  import fs from 'node:fs';
8
7
  import { sections, findSection } from './status-read.mjs';
9
8
  import { isMain } from './shared.mjs';
10
9
 
11
- const VOTE = /^\s*[-*]?\s*([A-Z]\d+)\s*[:=]\s*(DOĞRU|YANLIŞ|ABARTILI|AGREE|DISAGREE|OVERSTATED)(?!\p{L})/gimu; // \b does not work on Turkish letters (Ş)
12
- // The count keeps the Turkish vote names as its keys; English votes map onto them
13
- const VOTE_KEY = { AGREE: 'DOĞRU', DISAGREE: 'YANLIŞ', OVERSTATED: 'ABARTILI' };
14
- const OPINION = '(?:Görüş|Opinion)';
15
- const isCritical = (severity) => severity === 'KRİTİK' || severity === 'CRITICAL';
10
+ const VOTE = /^\s*[-*]?\s*([A-Z]\d+)\s*[:=]\s*(AGREE|DISAGREE|OVERSTATED)(?!\p{L})/gimu;
11
+ const isCritical = (severity) => severity === 'CRITICAL';
16
12
 
17
- const WORDS = {
18
- en: {
19
- heading: '3. Overall ranking', consensus: 'Consensus', disputed: 'Disputed', percent: (n) => `${n}%`,
20
- warning: (who) => `> Warning: the review by ${who} has no well-formed, complete RANKING (each Opinion once); it was left out of the ranking (its votes were counted). Ask the reviewer for the RANKING again.`,
21
- ranking: '| Opinion | Average rank | Rankers |', options: '| Option | Proposed in first opinions |',
22
- findings: '| Finding | Severity | Agree | Disagree | Overstated | Status |',
23
- noVotes: 'no votes', isDisputed: 'disputed', ask: 'ask the user', accepted: 'accepted',
24
- },
25
- tr: {
26
- heading: '3. Toplu sıralama', consensus: 'Uzlaşma', disputed: 'Tartışmalı', percent: (n) => `%${n}`,
27
- warning: (who) => `> Warning: the review by ${who} has no well-formed, complete SIRALAMA (each Görüş once); it was left out of the ranking (its votes were counted). Ask the reviewer for the SIRALAMA again.`,
28
- ranking: '| Görüş | Ortalama sıra | Sıralayan |', options: '| Seçenek | İlk görüşte öneren |',
29
- findings: '| Bulgu | Önem | Doğru | Yanlış | Abartılı | Durum |',
30
- noVotes: 'oy yok', isDisputed: 'tartışmalı', ask: 'kullanıcıya sor', accepted: 'kabul',
31
- },
13
+ const W = {
14
+ heading: '3. Overall ranking', consensus: 'Consensus', disputed: 'Disputed', percent: (n) => `${n}%`,
15
+ warning: (who) => `> Warning: the review by ${who} has no well-formed, complete RANKING (each Opinion once); it was left out of the ranking (its votes were counted). Ask the reviewer for the RANKING again.`,
16
+ ranking: '| Opinion | Average rank | Rankers |', options: '| Option | Proposed in first opinions |',
17
+ findings: '| Finding | Severity | Agree | Disagree | Overstated | Status |',
18
+ noVotes: 'no votes', isDisputed: 'disputed', ask: 'ask the user', accepted: 'accepted',
32
19
  };
33
- // Turkish minutes (before 3.48) name the opinions "Görüş" and the reviewers "İnceleyen"
34
- const minutesLang = (text) => (/^###\s*(?:Görüş|İnceleyen)(?!\p{L})/mu.test(text) ? 'tr' : 'en');
35
20
 
36
- // Numbered "Opinion X" lines after "RANKING:" (Turkish: "Görüş X" after "SIRALAMA:"). Without the heading or numbered lines it returns empty:
21
+ // Numbered "Opinion X" lines after "RANKING:". Without the heading or numbered lines it returns empty:
37
22
  // guessing an order from prose silently gives a wrong count, so that review counts as invalid.
38
23
  export function parseRanking(text) {
39
- const part = text.split(/SIRALAMA:|RANKING:/i)[1] ?? '';
40
- return [...new Set([...part.matchAll(new RegExp(`^\\s*\\d+\\.\\s*\\**\\s*${OPINION}\\s+([A-Z])\\b`, 'gmu'))].map((m) => m[1]))];
24
+ const part = text.split(/RANKING:/i)[1] ?? '';
25
+ return [...new Set([...part.matchAll(/^\s*\d+\.\s*\**\s*Opinion\s+([A-Z])\b/gmu)].map((m) => m[1]))];
41
26
  }
42
27
 
43
28
  export function parseBallots(text) {
44
- const part = text.split(/OYLAR:|VOTES:/i)[1]?.split(/SIRALAMA:|RANKING:/i)[0] ?? '';
45
- // Plain uppercase: the Turkish vote words have no dotted i, and "disagree" must not become "DİSAGREE"
46
- return Object.fromEntries([...part.matchAll(VOTE)].map((m) => [m[1].toUpperCase(), VOTE_KEY[m[2].toUpperCase()] ?? m[2].toUpperCase()]));
29
+ const part = text.split(/VOTES:/i)[1]?.split(/RANKING:/i)[0] ?? '';
30
+ // Plain uppercase: a Turkish locale would turn "disagree" into "DİSAGREE"
31
+ return Object.fromEntries([...part.matchAll(VOTE)].map((m) => [m[1].toUpperCase(), m[2].toUpperCase()]));
47
32
  }
48
33
 
49
34
  // From the first opinions: finding ids, severity and (in a decision council) the proposed option
50
35
  export function parseOpinions(firstOpinions) {
51
36
  const opinions = {};
52
37
  for (const [title, body] of Object.entries(sections(firstOpinions, '### '))) {
53
- const label = title.match(new RegExp(`${OPINION}\\s+([A-Z])`, 'u'))?.[1];
38
+ const label = title.match(/Opinion\s+([A-Z])/u)?.[1];
54
39
  if (!label) continue;
55
- const findings = [...body.matchAll(/^\s*[-*]?\s*([A-Z]\d+)\s*\[(KRİTİK|ÖNEMLİ|ÖNERİ|CRITICAL|IMPORTANT|SUGGESTION)\]/gmu)].map((m) => ({ id: m[1], severity: m[2] }));
56
- const proposal = body.match(/^\s*(?:ÖNERİ|RECOMMENDATION):\s*(.+)$/mu)?.[1].trim() ?? null;
40
+ const findings = [...body.matchAll(/^\s*[-*]?\s*([A-Z]\d+)\s*\[(CRITICAL|IMPORTANT|SUGGESTION)\]/gmu)].map((m) => ({ id: m[1], severity: m[2] }));
41
+ const proposal = body.match(/^\s*RECOMMENDATION:\s*(.+)$/mu)?.[1].trim() ?? null;
57
42
  opinions[label] = { findings, proposal };
58
43
  }
59
44
  return opinions;
@@ -62,8 +47,9 @@ export function parseOpinions(firstOpinions) {
62
47
  export function countVotes(minutes) {
63
48
  const b = sections(minutes);
64
49
  const opinions = parseOpinions(findSection(b, '1.'));
65
- const reviews = Object.entries(sections(findSection(b, '2.'), '### '))
66
- .map(([title, body]) => ({ reviewer: title.match(/(?:İnceleyen|Reviewer):\s*([A-Z])/u)?.[1] ?? '?', ranking: parseRanking(body), ballots: parseBallots(body) }));
50
+ // Only "### Reviewer: X" sections are reviews
51
+ const reviews = Object.entries(sections(findSection(b, '2.'), '### ')).filter(([title]) => /^Reviewer:/u.test(title))
52
+ .map(([title, body]) => ({ reviewer: title.match(/Reviewer:\s*([A-Z])/u)?.[1] ?? '?', ranking: parseRanking(body), ballots: parseBallots(body) }));
67
53
 
68
54
  // A ranking counts only as a full order of the other opinions (or of all of them), each once: a partial
69
55
  // "1. Opinion D" would give D a perfect average, and a letter with no opinion means nothing
@@ -81,11 +67,11 @@ export function countVotes(minutes) {
81
67
  .sort((x, y) => x.average - y.average || x.opinion.localeCompare(y.opinion));
82
68
 
83
69
  const findings = Object.values(opinions).flatMap((g) => g.findings).map((f) => {
84
- const s = { DOĞRU: 0, YANLIŞ: 0, ABARTILI: 0 };
70
+ const s = { AGREE: 0, DISAGREE: 0, OVERSTATED: 0 };
85
71
  for (const i of reviews) if (i.ballots[f.id]) s[i.ballots[f.id]]++;
86
- const objections = s.YANLIŞ + s.ABARTILI;
72
+ const objections = s.DISAGREE + s.OVERSTATED;
87
73
  // No vote at all is not acceptance: a reviewer skipped VOTES or the id
88
- return { ...f, ...s, disputed: objections > 0 && objections >= s.DOĞRU, unvoted: objections + s.DOĞRU === 0 };
74
+ return { ...f, ...s, disputed: objections > 0 && objections >= s.AGREE, unvoted: objections + s.AGREE === 0 };
89
75
  });
90
76
 
91
77
  const proposals = Object.values(opinions).map((g) => g.proposal).filter(Boolean);
@@ -94,34 +80,33 @@ export function countVotes(minutes) {
94
80
  const options = Object.entries(voteCounts).map(([option, votes]) => ({ option, votes })).sort((x, y) => y.votes - x.votes);
95
81
 
96
82
  // Consensus: in a decision council the share of the top option, in a meeting the share of "agree" among finding votes
97
- const totalVotes = findings.reduce((t, f) => t + f.DOĞRU + f.YANLIŞ + f.ABARTILI, 0);
83
+ const totalVotes = findings.reduce((t, f) => t + f.AGREE + f.DISAGREE + f.OVERSTATED, 0);
98
84
  const consensus = options.length
99
85
  ? Math.round((options[0].votes / proposals.length) * 100)
100
- : totalVotes ? Math.round((findings.reduce((t, f) => t + f.DOĞRU, 0) / totalVotes) * 100) : null;
86
+ : totalVotes ? Math.round((findings.reduce((t, f) => t + f.AGREE, 0) / totalVotes) * 100) : null;
101
87
  const invalid = reviews.filter((i) => !valid.includes(i)).map((i) => i.reviewer);
102
- return { ranking, findings, options, consensus, reviewerCount: reviews.length, invalid, lang: minutesLang(minutes) };
88
+ return { ranking, findings, options, consensus, reviewerCount: reviews.length, invalid };
103
89
  }
104
90
 
105
91
  export function renderSection(s) {
106
- const w = WORDS[s.lang] ?? WORDS.en;
107
92
  const lines = [];
108
- lines.push(`**${w.consensus}:** ${s.consensus == null ? '-' : w.percent(s.consensus)}`);
109
- lines.push(`**${w.disputed}:** ${s.findings.filter((f) => f.disputed).length}`, '');
110
- if (s.invalid?.length) lines.push(w.warning(s.invalid.join(', ')), '');
93
+ lines.push(`**${W.consensus}:** ${s.consensus == null ? '-' : W.percent(s.consensus)}`);
94
+ lines.push(`**${W.disputed}:** ${s.findings.filter((f) => f.disputed).length}`, '');
95
+ if (s.invalid?.length) lines.push(W.warning(s.invalid.join(', ')), '');
111
96
  if (s.ranking.length) {
112
- lines.push(w.ranking, '|---|---|---|');
97
+ lines.push(W.ranking, '|---|---|---|');
113
98
  for (const r of s.ranking) lines.push(`| ${r.opinion} | ${r.average} | ${r.votes} |`);
114
99
  lines.push('');
115
100
  }
116
101
  if (s.options.length) {
117
- lines.push(w.options, '|---|---|');
102
+ lines.push(W.options, '|---|---|');
118
103
  for (const o of s.options) lines.push(`| ${o.option} | ${o.votes} |`);
119
104
  lines.push('');
120
105
  }
121
106
  if (s.findings.length) {
122
- lines.push(w.findings, '|---|---|---|---|---|---|');
123
- const ask = (f, word) => (isCritical(f.severity) ? `${word} · ${w.ask}` : word);
124
- for (const f of s.findings) lines.push(`| ${f.id} | ${f.severity} | ${f.DOĞRU} | ${f.YANLIŞ} | ${f.ABARTILI} | ${f.unvoted ? ask(f, w.noVotes) : f.disputed ? ask(f, w.isDisputed) : w.accepted} |`);
107
+ lines.push(W.findings, '|---|---|---|---|---|---|');
108
+ const ask = (f, word) => (isCritical(f.severity) ? `${word} · ${W.ask}` : word);
109
+ for (const f of s.findings) lines.push(`| ${f.id} | ${f.severity} | ${f.AGREE} | ${f.DISAGREE} | ${f.OVERSTATED} | ${f.unvoted ? ask(f, W.noVotes) : f.disputed ? ask(f, W.isDisputed) : W.accepted} |`);
125
110
  lines.push('');
126
111
  }
127
112
  return lines.join('\n');
@@ -129,7 +114,7 @@ export function renderSection(s) {
129
114
 
130
115
  // Replaces the "## 3. ..." section in the minutes (if any), otherwise inserts before "## 4." or at the end
131
116
  export function writeMinutes(minutes, s) {
132
- const section = `## ${(WORDS[s.lang] ?? WORDS.en).heading}\n\n${renderSection(s).trimEnd()}\n\n`;
117
+ const section = `## ${W.heading}\n\n${renderSection(s).trimEnd()}\n\n`;
133
118
  // Replace with a function: sequences like "$&", "$$" in an option name must not get their special meaning
134
119
  if (/^## 3\./m.test(minutes)) return minutes.replace(/^## 3\.[^\n]*\n[\s\S]*?(?=^## |(?![\s\S]))/m, () => section);
135
120
  if (/^## 4\./m.test(minutes)) return minutes.replace(/^## 4\./m, () => `${section}## 4.`);
@@ -141,7 +126,7 @@ if (isMain(import.meta.url)) {
141
126
  if (!file || !fs.existsSync(file)) { console.error('Usage: node .claude/hooks/council.mjs <minutes.md>'); process.exit(1); }
142
127
  const text = fs.readFileSync(file, 'utf8');
143
128
  const s = countVotes(text);
144
- if (!s.reviewerCount) { console.error('The minutes have no "### Reviewer: X" section under "## 2. Cross review" (before 3.48: "### İnceleyen: X" under "## 2. Çapraz inceleme").'); process.exit(1); }
129
+ if (!s.reviewerCount) { console.error('The minutes have no "### Reviewer: X" section under "## 2. Cross review".'); process.exit(1); }
145
130
  fs.writeFileSync(file, writeMinutes(text, s));
146
131
  console.log(renderSection(s));
147
132
  }
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { readText, sections, findSection, tableRows, labels } from './status-read.mjs';
7
- import { normalize, isValue, VALUES } from './labels.mjs';
7
+ import { isValue, VALUES } from './labels.mjs';
8
8
  import { t } from './lang.mjs';
9
9
 
10
10
  const DAY = 864e5;
@@ -12,13 +12,13 @@ const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s).trim());
12
12
  export const daysLeft = (date, now = Date.now()) => Math.ceil((Date.parse(`${date}T00:00:00`) - now) / DAY);
13
13
 
14
14
  // ---------- acceptance tests ----------
15
- // A criterion id is "AC-01.1" in the PRD and "AC-01.1" or "AC_01_1" in tests; before 3.48 "Ö-01.1" / "O_01_1".
16
- // All four normalize to the key "O_01_1". Not after a letter: "ISO-27.1", "DEMO_01_2" or "MAC_01_1" is not a criterion
17
- const CRITERION_ID = /(?<![A-Za-z])(?:AC|[ÖO])[-_](\d{2})[._](\d+)/g;
18
- const key = (m1, m2) => `O_${m1}_${m2}`;
15
+ // A criterion id is "AC-01.1" in the PRD and "AC-01.1" or "AC_01_1" in tests.
16
+ // Both normalize to the key "AC_01_1". Not after a letter: "ISO-27.1", "DEMO_01_2" or "MAC_01_1" is not a criterion
17
+ const CRITERION_ID = /(?<![A-Za-z])AC[-_](\d{2})[._](\d+)/g;
18
+ const key = (m1, m2) => `AC_${m1}_${m2}`;
19
19
 
20
20
  export function prdCriteria(prdText) {
21
- return [...prdText.matchAll(/^\s*- \[( |x)\]\s*((?:AC|Ö)-(\d{2})\.(\d+))\s*:?\s*(.*)$/gim)]
21
+ return [...prdText.matchAll(/^\s*- \[( |x)\]\s*(AC-(\d{2})\.(\d+))\s*:?\s*(.*)$/gim)]
22
22
  .filter((m) => !m[5].startsWith('['))
23
23
  .map((m) => ({ id: m[2], key: key(m[3], m[4]), text: m[5].trim(), checked: m[1].toLowerCase() === 'x' }));
24
24
  }
@@ -124,9 +124,9 @@ export function specInvariants(root, since = 0) {
124
124
  for (const f of files) {
125
125
  const body = sections(readText(path.join(dir, f)))['Behavior that must not change'] ?? '';
126
126
  for (const line of body.split(/\r?\n/)) {
127
- const m = line.match(/^\s*-\s*((?:AC|Ö)-(\d{2})\.(\d+))/);
127
+ const m = line.match(/^\s*-\s*(AC-(\d{2})\.(\d+))/);
128
128
  if (m) list.push({ spec: f, id: m[1], key: key(m[2], m[3]) });
129
- else if (/(?<![A-Za-z])(?:AC|[ÖO])[-_]\d{2}[._]\d+/.test(line)) unreadable.push({ spec: f, line: line.trim() });
129
+ else if (/(?<![A-Za-z])AC[-_]\d{2}[._]\d+/.test(line)) unreadable.push({ spec: f, line: line.trim() });
130
130
  }
131
131
  }
132
132
  if (!list.length) return { invariants: [], unreadable };
@@ -136,11 +136,9 @@ export function specInvariants(root, since = 0) {
136
136
  }
137
137
 
138
138
  // Bug classes seen in two or more bug specs (F-21): a repeat is the cue to propose a rule. Only the fixed list counts
139
- // ("diğer"/"other" and free text never do). A group closes once every spec has a "**Rule:**" answer (rule added or not needed);
139
+ // ("other" and free text never do). A group closes once every spec has a "**Rule:**" answer (rule added or not needed);
140
140
  // a new undecided spec in a decided class opens it again (the rule did not prevent the bug).
141
141
  export const BUG_CLASSES = ['empty-input', 'validation', 'authorization', 'date-time', 'locale', 'sorting', 'concurrency', 'configuration', 'external-service', 'contract', 'ui-state'];
142
- // Pre-3.48 specs name the class in Turkish, in the same order; both count as one class under the English name
143
- const BUG_CLASSES_TR = ['boş-girdi', 'doğrulama', 'yetki', 'tarih-saat', 'yerel-dil', 'sıralama', 'eşzamanlılık', 'yapılandırma', 'dış-servis', 'sözleşme', 'arayüz-durumu'];
144
142
  export function repeatedBugClasses(root) {
145
143
  const dir = path.join(root, 'docs/product/specs');
146
144
  let files = [];
@@ -150,9 +148,7 @@ export function repeatedBugClasses(root) {
150
148
  const text = readText(path.join(dir, f));
151
149
  if (!isValue(text.match(/^\*\*Type:\*\*[ \t]*(.*)$/im)?.[1], 'bug')) continue;
152
150
  const raw = text.match(/^\*\*Class:\*\*[ \t]*`?([^\s`,.[\]]+)/im)?.[1] ?? '';
153
- // YETKI and YETKİ both mean yetki: try the Turkish and the plain lowercase
154
- const cls = [raw.toLocaleLowerCase('tr'), raw.toLowerCase()]
155
- .map((c) => (BUG_CLASSES.includes(c) ? c : BUG_CLASSES[BUG_CLASSES_TR.indexOf(c)])).find(Boolean);
151
+ const cls = BUG_CLASSES.find((c) => c === raw.toLowerCase());
156
152
  if (!cls) continue;
157
153
  const decided = /^\*\*Rule:\*\*[ \t]*[^\s[]/im.test(text);
158
154
  byClass.set(cls, [...(byClass.get(cls) ?? []), { f, decided }]);
@@ -162,7 +158,7 @@ export function repeatedBugClasses(root) {
162
158
 
163
159
  // ---------- accounts and flags ----------
164
160
  export function accounts(text, now = Date.now()) {
165
- return tableRows(sections(normalize(text))['Accounts'], 8)
161
+ return tableRows(sections(text)['Accounts'], 8)
166
162
  .filter(([service]) => service && !service.startsWith('['))
167
163
  .map(([service, purpose, owner, twoFactor, plan, expiry, keyRotation]) => {
168
164
  const dates = [['expiry', expiry], ['keyRotation', keyRotation]].filter(([, t]) => isDate(t))
@@ -175,7 +171,7 @@ export function accounts(text, now = Date.now()) {
175
171
  export function envWarnings(root) {
176
172
  const u = [];
177
173
  const prodRef = tableRows(sections(readText(path.join(root, 'docs/operations/accounts.md')))['Environments (dev / prod)'], 4)
178
- // A real ref (Supabase: 20 characters); "-", "yok", "n/a" would match anywhere in .mcp.json
174
+ // A real ref (Supabase: 20 characters); "-", "none", "n/a" would match anywhere in .mcp.json
179
175
  .find(([environment, , ref]) => environment === 'prod' && /^[a-z0-9][a-z0-9._:/-]{7,}$/i.test(ref ?? ''))?.[2];
180
176
  if (prodRef && readText(path.join(root, '.mcp.json')).includes(prodRef)) {
181
177
  u.push({ sev: 'critical', text: t(root, 'dashboardData.prodRefInMcp', { ref: prodRef }), action: t(root, 'dashboardData.prodRefAction') });
@@ -187,17 +183,17 @@ export function envWarnings(root) {
187
183
  }
188
184
 
189
185
  export function flags(text, now = Date.now()) {
190
- return tableRows(sections(normalize(text))['Flags'], 7).filter(([name]) => name)
186
+ return tableRows(sections(text)['Flags'], 7).filter(([name]) => name)
191
187
  .map(([name, purpose, state, owner, added, removal, killSwitchTested]) => ({
192
188
  name, purpose, state, owner, added, removal, killSwitchTested,
193
189
  isOverdue: isDate(removal) && daysLeft(removal, now) < 0,
194
- untested: !(isValue(killSwitchTested, 'yes') || /^(✅|var\b)/i.test(killSwitchTested ?? '')),
190
+ untested: !(isValue(killSwitchTested, 'yes') || /^✅/.test(killSwitchTested ?? '')),
195
191
  }));
196
192
  }
197
193
 
198
194
  // ---------- unit economics ----------
199
195
  export function unitEconomics(text) {
200
- const e = labels(normalize(text));
196
+ const e = labels(text);
201
197
  const s = (k) => { const v = parseFloat(String(e[k] ?? '').replace(/[^\d.,-]/g, '').replace(',', '.')); return Number.isFinite(v) ? v : 0; };
202
198
  const price = s('Price (per user, monthly)');
203
199
  const commission = s('Payment fee (%)');
@@ -226,9 +222,9 @@ export function unitEconomics(text) {
226
222
  const FEEDBACK_KINDS = ['bug', 'request', 'complaint', 'praise', 'question'];
227
223
  const FEEDBACK_STATES = ['new', 'planned', 'done', 'rejected', 'merged'];
228
224
  // A known value in either language becomes the English one; anything else stays as written
229
- const inEnglish = (v, keys) => VALUES[keys.find((k) => isValue(v, k))]?.en ?? v;
225
+ const inEnglish = (v, keys) => VALUES[keys.find((k) => isValue(v, k))]?? v;
230
226
  export function feedback(text) {
231
- const b = sections(normalize(text));
227
+ const b = sections(text);
232
228
  const records = tableRows(b['Inbox'], 9).map(([no, date, source, user, summary, kind, theme, priority, state]) =>
233
229
  ({ no, date, summary, kind: inEnglish(kind, FEEDBACK_KINDS), theme, priority, state: inEnglish(state, FEEDBACK_STATES) }));
234
230
  const themes = tableRows(b['Themes'], 3).map(([theme, count, feature, decision]) => ({ theme, count: parseInt(count, 10) || 0, feature, decision }))
@@ -239,7 +235,7 @@ export function feedback(text) {
239
235
  // Comprehension check answers (F-22, narrator mode); null without rows so the card is left out
240
236
  export function learning(text) {
241
237
  // Only right / wrong rows count; a skipped or malformed answer is not a wrong one
242
- const rows = tableRows(sections(normalize(text))['Records'], 4).filter(([, , , result]) => isValue(result, 'right') || isValue(result, 'wrong'))
238
+ const rows = tableRows(sections(text)['Records'], 4).filter(([, , , result]) => isValue(result, 'right') || isValue(result, 'wrong'))
243
239
  .map(([date, task, concept, result]) => ({ concept, right: isValue(result, 'right') }));
244
240
  if (!rows.length) return null;
245
241
  const missed = new Map();
@@ -249,28 +245,26 @@ export function learning(text) {
249
245
  }
250
246
 
251
247
  export function metrics(text) {
252
- return tableRows(sections(normalize(text))['Records'], 9)
248
+ return tableRows(sections(text)['Records'], 9)
253
249
  .map(([period, taskCount, duration, limit, fixes, reverts, duplicates, critical, acceptance]) => ({ period, taskCount, duration, limit, fixes, reverts, duplicates, critical, acceptance }));
254
250
  }
255
251
 
256
252
  // ---------- finding reports ----------
257
253
  export const FINDING_KINDS = { tech: 'Tech', business: 'Business', security: 'Security', ux: 'UX', perf: 'Performance', legal: 'Legal' };
258
- // Pre-3.48 report tables name the area in Turkish
259
- const FINDING_KINDS_TR = { tech: 'Teknik', business: 'İş', security: 'Güvenlik', ux: 'UX', perf: 'Performans', legal: 'Hukuk' };
260
254
  const findingKind = (v) => {
261
- const low = [v.toLocaleLowerCase('tr'), v.toLowerCase()];
262
- const named = Object.keys(FINDING_KINDS).find((k) => [k, FINDING_KINDS[k], FINDING_KINDS_TR[k]].some((n) => low.includes(n.toLocaleLowerCase('tr')) || low.includes(n.toLowerCase())));
263
- return named ?? low[1];
255
+ const low = v.toLowerCase();
256
+ return Object.keys(FINDING_KINDS).find((k) => k === low || FINDING_KINDS[k].toLowerCase() === low) ?? low;
264
257
  };
265
258
  export function findingReports(text) {
266
259
  const count = (x) => { const n = parseInt(String(x ?? '').trim().replace(/^[^\d-]+/, ''), 10); return Number.isFinite(n) ? n : null; };
267
- const rows = tableRows(sections(normalize(text))['Reports'], 7)
260
+ const rows = tableRows(sections(text)['Reports'], 7)
268
261
  .map(([date, kind, health, previous, findings, critical, report]) => ({
269
262
  date, kind: findingKind(kind), health: count(health), previous: count(previous), findings: count(findings), critical: count(critical) ?? 0, report,
270
263
  }))
271
264
  .filter((r) => r.date && r.health != null);
272
265
  const latest = {};
273
- for (const r of rows) if (!latest[r.kind] || r.date >= latest[r.kind].date) latest[r.kind] = r;
266
+ // An area name the kit does not know is listed, but has no card
267
+ for (const r of rows) if (Object.hasOwn(FINDING_KINDS, r.kind) && (!latest[r.kind] || r.date >= latest[r.kind].date)) latest[r.kind] = r;
274
268
  return { rows, latest };
275
269
  }
276
270
 
@@ -299,7 +293,7 @@ export function producedAssets(root) {
299
293
 
300
294
  // ---------- Spend alarms (docs/operations/spend-alarms.md) ----------
301
295
  export function spendAlarms(text, now = Date.now()) {
302
- return tableRows(sections(normalize(text))['Alarms'], 6).filter(([service]) => service)
296
+ return tableRows(sections(text)['Alarms'], 6).filter(([service]) => service)
303
297
  .map(([service, kind, amount, thresholds, where, lastCheck]) => ({
304
298
  service, kind, amount, thresholds, where, lastCheck,
305
299
  unset: !/\d/.test(amount ?? ''),
@@ -316,21 +310,19 @@ export function councilMinutes(root) {
316
310
  const text = readText(path.join(folder, f));
317
311
  const a = {};
318
312
  for (const [, k, v] of text.matchAll(/\*\*([^*:]+):\*\*\s*([^·\n]*)/g)) a[k.trim()] ??= v.trim();
319
- // English minutes, or Turkish ones written before 3.48 (they stay as they are: a council record is history)
320
- const pick = (en, tr) => a[en] ?? a[tr];
321
313
  const b = sections(text);
322
314
  const ranking = tableRows(findSection(b, '3.'), 3).filter(([g]) => /^[A-Z]$/.test(g));
323
- const idsByLetter = Object.fromEntries([...(b['IDs'] ?? b['Kimlikler'] ?? '').matchAll(/([A-Z])\s*=\s*([^·\n]+)/g)].map((m) => [m[1], m[2].trim()]));
324
- const consensus = pick('Consensus', 'Uzlaşma')?.match(/\d+/)?.[0];
315
+ const idsByLetter = Object.fromEntries([...(b['IDs'] ?? '').matchAll(/([A-Z])\s*=\s*([^·\n]+)/g)].map((m) => [m[1], m[2].trim()]));
316
+ const consensus = a['Consensus']?.match(/\d+/)?.[0];
325
317
  return {
326
318
  file: `docs/workflow/council/${f}`,
327
- topic: text.match(/^#\s+(?:Council|Konsey):\s*(.+)$/m)?.[1] ?? f.replace(/\.md$/, ''),
328
- date: pick('Date', 'Tarih') ?? f.slice(0, 10),
329
- kind: pick('Type', 'Tür') ?? '-',
330
- decision: pick('Decision', 'Karar') || pick('Recommendation', 'Öneri') || null,
319
+ topic: text.match(/^#\s+Council:\s*(.+)$/m)?.[1] ?? f.replace(/\.md$/, ''),
320
+ date: a['Date'] ?? f.slice(0, 10),
321
+ kind: a['Type'] ?? '-',
322
+ decision: a['Decision'] || a['Recommendation'] || null,
331
323
  consensus: consensus ? Number(consensus) : null,
332
- disputed: Number(pick('Disputed', 'Tartışmalı') ?? 0) || 0,
333
- eliminated: Number(pick('Eliminated', 'Elenen') ?? 0) || 0,
324
+ disputed: Number(a['Disputed'] ?? 0) || 0,
325
+ eliminated: Number(a['Eliminated'] ?? 0) || 0,
334
326
  ranking: ranking.map(([g, avg]) => ({ opinion: g, who: idsByLetter[g] ?? null, average: avg })),
335
327
  };
336
328
  }).sort((x, y) => y.date.localeCompare(x.date) || y.file.localeCompare(x.file));
@@ -374,7 +366,7 @@ export function githubStatus(root, now = Date.now()) {
374
366
  }
375
367
  // Separate call: the live workflow may be missing (mobile) or older than the last 8 runs; a failure here must not hide the rest
376
368
  if (data) try { data.liveRun = gh('run', 'list', '--workflow', 'live-monitoring.yml', '--limit', '1', '--json', 'conclusion,status,createdAt,url')[0] ?? null; } catch { data.liveRun = null; }
377
- try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ time: now, data })); } catch { /* sessiz */ }
369
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ time: now, data })); } catch { /* silent */ }
378
370
  return safe(data);
379
371
  }
380
372