tillerhand 3.47.1 → 3.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.claude/agents/board-chair.md +9 -9
  2. package/.claude/agents/code-reviewer.md +2 -2
  3. package/.claude/agents/council-advisor.md +2 -2
  4. package/.claude/agents/security-auditor.md +6 -6
  5. package/.claude/agents/test-writer.md +1 -1
  6. package/.claude/board/board-ai.md +6 -6
  7. package/.claude/board/board-api.md +6 -6
  8. package/.claude/board/board-architect.md +7 -7
  9. package/.claude/board/board-devops.md +6 -6
  10. package/.claude/board/board-mobile.md +6 -6
  11. package/.claude/board/board-performance.md +6 -6
  12. package/.claude/board/board-privacy.md +6 -6
  13. package/.claude/board/board-product.md +7 -7
  14. package/.claude/board/board-qa.md +7 -7
  15. package/.claude/board/board-ux.md +6 -6
  16. package/.claude/board/council.md +32 -32
  17. package/.claude/board/rules.md +8 -8
  18. package/.claude/commands/adopt.md +10 -10
  19. package/.claude/commands/ask-mode.md +6 -5
  20. package/.claude/commands/board.md +1 -1
  21. package/.claude/commands/build.md +8 -8
  22. package/.claude/commands/design.md +8 -8
  23. package/.claude/commands/findings-all.md +2 -2
  24. package/.claude/commands/findings-security.md +1 -1
  25. package/.claude/commands/fix-bug.md +2 -2
  26. package/.claude/commands/handoff.md +12 -12
  27. package/.claude/commands/language.md +1 -1
  28. package/.claude/commands/maintain.md +2 -2
  29. package/.claude/commands/plan-task.md +3 -3
  30. package/.claude/commands/qa.md +5 -5
  31. package/.claude/commands/release.md +3 -3
  32. package/.claude/commands/research.md +2 -2
  33. package/.claude/commands/retro.md +2 -2
  34. package/.claude/commands/review-changes.md +2 -2
  35. package/.claude/commands/rollback.md +1 -1
  36. package/.claude/commands/start.md +20 -20
  37. package/.claude/commands/tools.md +8 -6
  38. package/.claude/commands/user-feedback.md +3 -3
  39. package/.claude/commands/wrap-up.md +3 -3
  40. package/.claude/findings/protocol.md +2 -2
  41. package/.claude/findings/report-template.md +1 -1
  42. package/.claude/hooks/commit-gate.mjs +11 -9
  43. package/.claude/hooks/council.mjs +51 -24
  44. package/.claude/hooks/dashboard-data.mjs +73 -53
  45. package/.claude/hooks/dashboard.mjs +80 -71
  46. package/.claude/hooks/destructive-guard.mjs +1 -1
  47. package/.claude/hooks/freeze-guard.mjs +1 -1
  48. package/.claude/hooks/gate.mjs +14 -11
  49. package/.claude/hooks/labels.mjs +177 -0
  50. package/.claude/hooks/messages.mjs +8 -7
  51. package/.claude/hooks/notify.mjs +2 -1
  52. package/.claude/hooks/prefs.mjs +24 -10
  53. package/.claude/hooks/preview.mjs +6 -6
  54. package/.claude/hooks/progress.mjs +27 -15
  55. package/.claude/hooks/qa-report.mjs +12 -10
  56. package/.claude/hooks/session-start.mjs +9 -7
  57. package/.claude/hooks/status-read.mjs +22 -18
  58. package/.claude/hooks/statusline.mjs +10 -8
  59. package/.claude/hooks/tools.mjs +13 -8
  60. package/.claude/output-styles/{anlatici.md → narrator.md} +2 -2
  61. package/.claude/skills/logo-design/SKILL.md +2 -2
  62. package/.claude/skills/source-research/SKILL.md +1 -1
  63. package/.claude/skills/ui-motion/SKILL.md +2 -2
  64. package/AGENTS.md +12 -12
  65. package/CLAUDE.md +1 -1
  66. package/README.md +2 -2
  67. package/README.tr.md +3 -3
  68. package/docs/decisions/adr-template.md +1 -1
  69. package/docs/decisions/log.md +2 -2
  70. package/docs/findings/README.md +1 -1
  71. package/docs/operations/accounts.md +7 -7
  72. package/docs/operations/deploy-setup.md +3 -3
  73. package/docs/operations/feature-flags.md +2 -2
  74. package/docs/operations/maintenance.md +1 -1
  75. package/docs/operations/release-checklist.md +2 -2
  76. package/docs/operations/spend-alarms.md +2 -2
  77. package/docs/product/feedback.md +4 -4
  78. package/docs/product/launch.md +1 -1
  79. package/docs/product/prd.md +10 -10
  80. package/docs/product/roadmap.md +8 -8
  81. package/docs/product/specs/_template.md +8 -8
  82. package/docs/product/unit-economics.md +10 -10
  83. package/docs/quality/metrics.md +2 -2
  84. package/docs/quality/test-plan.md +5 -5
  85. package/docs/security/checklist.md +2 -2
  86. package/docs/workflow/board.md +5 -5
  87. package/docs/workflow/cost-log.md +1 -1
  88. package/docs/workflow/decision-protocol.md +18 -16
  89. package/docs/workflow/learning.md +2 -2
  90. package/docs/workflow/mcp-catalog.md +1 -1
  91. package/docs/workflow/models.md +1 -1
  92. package/docs/workflow/progress.md +14 -14
  93. package/docs/workflow/skills.md +9 -9
  94. package/docs/workflow/usage-limits.md +5 -5
  95. package/kit/CHANGELOG.md +28 -0
  96. package/kit/VERSION +1 -1
  97. package/kit/guide.html +21 -21
  98. package/kit/guide.tr.html +21 -21
  99. package/kit/kit.mjs +29 -0
  100. package/kit/project/README.md +1 -1
  101. package/kit/templates/addons/ai/ai/evals/README.md +5 -5
  102. package/kit/templates/addons/ai/ai/evals/cases.jsonl +3 -3
  103. package/kit/templates/addons/ai/docs/ai/guide.md +1 -1
  104. package/kit/templates/backend/.github/workflows/ci.yml +4 -4
  105. package/kit/templates/backend/.github/workflows/claude-ci-fix.yml +10 -10
  106. package/kit/templates/backend/.github/workflows/claude-maintenance.yml +1 -1
  107. package/kit/templates/backend/.github/workflows/live-monitoring.yml +1 -1
  108. package/kit/templates/backend/.pre-commit-config.yaml +1 -1
  109. package/kit/templates/backend/docs/architecture/stack.md +7 -7
  110. package/kit/templates/backend/docs/architecture/structure.md +2 -2
  111. package/kit/templates/mobile/.github/workflows/ci.yml +4 -4
  112. package/kit/templates/mobile/.github/workflows/claude-ci-fix.yml +10 -10
  113. package/kit/templates/mobile/.github/workflows/claude-maintenance.yml +1 -1
  114. package/kit/templates/mobile/docs/architecture/stack.md +5 -5
  115. package/kit/templates/mobile/docs/architecture/structure.md +1 -1
  116. package/kit/templates/mobile/docs/design/design-guide.md +1 -1
  117. package/kit/templates/mobile/tests/security/rls.mjs +4 -4
  118. package/kit/templates/web-saas/.github/workflows/ci.yml +4 -4
  119. package/kit/templates/web-saas/.github/workflows/claude-ci-fix.yml +10 -10
  120. package/kit/templates/web-saas/.github/workflows/claude-maintenance.yml +1 -1
  121. package/kit/templates/web-saas/.github/workflows/live-monitoring.yml +1 -1
  122. package/kit/templates/web-saas/docs/architecture/stack.md +12 -12
  123. package/kit/templates/web-saas/docs/architecture/structure.md +2 -2
  124. package/kit/templates/web-saas/docs/design/design-guide.md +1 -1
  125. package/kit/templates/web-saas/tests/security/rls.mjs +4 -4
  126. package/package.json +1 -1
@@ -3,7 +3,8 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { readText, sections, tableRows, labels } from './status-read.mjs';
6
+ import { readText, sections, findSection, tableRows, labels } from './status-read.mjs';
7
+ import { normalize, isValue, VALUES } from './labels.mjs';
7
8
  import { t } from './lang.mjs';
8
9
 
9
10
  const DAY = 864e5;
@@ -11,13 +12,13 @@ const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(String(s).trim());
11
12
  export const daysLeft = (date, now = Date.now()) => Math.ceil((Date.parse(`${date}T00:00:00`) - now) / DAY);
12
13
 
13
14
  // ---------- acceptance tests ----------
14
- // A criterion id is "Ö-01.1" in the PRD and "Ö-01.1" or "O_01_1" in tests; all normalize to the key "O_01_1".
15
- // Not after a letter: "ISO-27.1" or "DEMO_01_2" is not a criterion
16
- const CRITERION_ID = /(?<![A-Za-z])[ÖO][-_](\d{2})[._](\d+)/g;
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;
17
18
  const key = (m1, m2) => `O_${m1}_${m2}`;
18
19
 
19
20
  export function prdCriteria(prdText) {
20
- return [...prdText.matchAll(/^\s*- \[( |x)\]\s*(Ö-(\d{2})\.(\d+))\s*:?\s*(.*)$/gim)]
21
+ return [...prdText.matchAll(/^\s*- \[( |x)\]\s*((?:AC|Ö)-(\d{2})\.(\d+))\s*:?\s*(.*)$/gim)]
21
22
  .filter((m) => !m[5].startsWith('['))
22
23
  .map((m) => ({ id: m[2], key: key(m[3], m[4]), text: m[5].trim(), checked: m[1].toLowerCase() === 'x' }));
23
24
  }
@@ -81,8 +82,8 @@ export function junitResults(xmlTexts) {
81
82
  export function qaSummary(text) {
82
83
  const rows = tableRows(text, 4).map(([criterion, result, evidence, note]) => ({ criterion, result, evidence, note }));
83
84
  if (!rows.length) return null;
84
- const count = (r) => rows.filter((x) => x.result === r).length;
85
- return { passed: count('geçti'), failed: count('kaldı'), notRun: count('koşulmadı'), failures: rows.filter((x) => x.result === 'kaldı') };
85
+ const count = (k) => rows.filter((x) => isValue(x.result, k)).length;
86
+ return { passed: count('passed'), failed: count('failed'), notRun: count('notRun'), failures: rows.filter((x) => isValue(x.result, 'failed')) };
86
87
  }
87
88
 
88
89
  function junitFiles(root) {
@@ -111,21 +112,21 @@ export function acceptanceStatus(root) {
111
112
  };
112
113
  }
113
114
 
114
- // "Must not change" lines of the specs (F-23): each Ö id, whether a test carries it and its JUnit result.
115
+ // "Must not change" lines of the specs (F-23): each AC id, whether a test carries it and its JUnit result.
115
116
  // since: only reports written at or after this time count (the gate passes its own start: an old red report must not
116
117
  // stop a commit whose tests pass now). Files starting with "_" (the template) are skipped. unreadable: lines that name
117
- // an id but not in the "- Ö-01.1: ..." form, so the check would silently skip them.
118
+ // an id but not in the "- AC-01.1: ..." form, so the check would silently skip them.
118
119
  export function specInvariants(root, since = 0) {
119
120
  const dir = path.join(root, 'docs/product/specs');
120
121
  let files = [];
121
122
  try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('_')).sort(); } catch { return { invariants: [], unreadable: [] }; }
122
123
  const list = [], unreadable = [];
123
124
  for (const f of files) {
124
- const body = sections(readText(path.join(dir, f)))['Değişmemesi gereken davranış'] ?? '';
125
+ const body = sections(readText(path.join(dir, f)))['Behavior that must not change'] ?? '';
125
126
  for (const line of body.split(/\r?\n/)) {
126
- const m = line.match(/^\s*-\s*(Ö-(\d{2})\.(\d+))/);
127
+ const m = line.match(/^\s*-\s*((?:AC|Ö)-(\d{2})\.(\d+))/);
127
128
  if (m) list.push({ spec: f, id: m[1], key: key(m[2], m[3]) });
128
- else if (/[ÖO][-_]\d{2}[._]\d+/.test(line)) unreadable.push({ spec: f, line: line.trim() });
129
+ else if (/(?<![A-Za-z])(?:AC|[ÖO])[-_]\d{2}[._]\d+/.test(line)) unreadable.push({ spec: f, line: line.trim() });
129
130
  }
130
131
  }
131
132
  if (!list.length) return { invariants: [], unreadable };
@@ -135,9 +136,11 @@ export function specInvariants(root, since = 0) {
135
136
  }
136
137
 
137
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
138
- // ("diğer" and free text never do). A group closes once every spec has a "**Kural:**" answer (rule added or not needed);
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
140
  // a new undecided spec in a decided class opens it again (the rule did not prevent the bug).
140
- export const BUG_CLASSES = ['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'];
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'];
141
144
  export function repeatedBugClasses(root) {
142
145
  const dir = path.join(root, 'docs/product/specs');
143
146
  let files = [];
@@ -145,12 +148,13 @@ export function repeatedBugClasses(root) {
145
148
  const byClass = new Map();
146
149
  for (const f of files) {
147
150
  const text = readText(path.join(dir, f));
148
- if (!/^\*\*Tür:\*\*[ \t]*hata\b/im.test(text)) continue;
149
- const raw = text.match(/^\*\*Sınıf:\*\*[ \t]*`?([^\s`,.[\]]+)/im)?.[1] ?? '';
151
+ if (!isValue(text.match(/^\*\*Type:\*\*[ \t]*(.*)$/im)?.[1], 'bug')) continue;
152
+ const raw = text.match(/^\*\*Class:\*\*[ \t]*`?([^\s`,.[\]]+)/im)?.[1] ?? '';
150
153
  // YETKI and YETKİ both mean yetki: try the Turkish and the plain lowercase
151
- const cls = [raw.toLocaleLowerCase('tr'), raw.toLowerCase()].find((c) => BUG_CLASSES.includes(c));
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);
152
156
  if (!cls) continue;
153
- const decided = /^\*\*Kural:\*\*[ \t]*[^\s[]/im.test(text);
157
+ const decided = /^\*\*Rule:\*\*[ \t]*[^\s[]/im.test(text);
154
158
  byClass.set(cls, [...(byClass.get(cls) ?? []), { f, decided }]);
155
159
  }
156
160
  return [...byClass].filter(([, list]) => list.length > 1 && list.some((s) => !s.decided)).map(([cls, list]) => ({ cls, specs: list.map((s) => s.f) }));
@@ -158,19 +162,19 @@ export function repeatedBugClasses(root) {
158
162
 
159
163
  // ---------- accounts and flags ----------
160
164
  export function accounts(text, now = Date.now()) {
161
- return tableRows(sections(text)['Hesaplar'], 8)
165
+ return tableRows(sections(normalize(text))['Accounts'], 8)
162
166
  .filter(([service]) => service && !service.startsWith('['))
163
167
  .map(([service, purpose, owner, twoFactor, plan, expiry, keyRotation]) => {
164
- const dates = [['bitiş', expiry], ['anahtar yenileme', keyRotation]].filter(([, t]) => isDate(t))
168
+ const dates = [['expiry', expiry], ['keyRotation', keyRotation]].filter(([, t]) => isDate(t))
165
169
  .map(([kind, t]) => ({ kind, date: t, left: daysLeft(t, now) }));
166
- return { service, purpose, owner, twoFactor, plan, dates, noTwoFactor: /^(hayır|yok|no)$/i.test(twoFactor) };
170
+ return { service, purpose, owner, twoFactor, plan, dates, noTwoFactor: isValue(twoFactor, 'no') || isValue(twoFactor, 'none') };
167
171
  });
168
172
  }
169
173
 
170
174
  // Dev/prod split and the RLS attacker test: the prod ref must not be in the AI's MCP config; with Supabase an RLS test must exist
171
175
  export function envWarnings(root) {
172
176
  const u = [];
173
- const prodRef = tableRows(sections(readText(path.join(root, 'docs/operations/accounts.md')))['Ortamlar (dev / prod)'], 4)
177
+ const prodRef = tableRows(sections(readText(path.join(root, 'docs/operations/accounts.md')))['Environments (dev / prod)'], 4)
174
178
  // A real ref (Supabase: 20 characters); "-", "yok", "n/a" would match anywhere in .mcp.json
175
179
  .find(([environment, , ref]) => environment === 'prod' && /^[a-z0-9][a-z0-9._:/-]{7,}$/i.test(ref ?? ''))?.[2];
176
180
  if (prodRef && readText(path.join(root, '.mcp.json')).includes(prodRef)) {
@@ -183,24 +187,24 @@ export function envWarnings(root) {
183
187
  }
184
188
 
185
189
  export function flags(text, now = Date.now()) {
186
- return tableRows(sections(text)['Bayraklar'], 7).filter(([name]) => name)
190
+ return tableRows(sections(normalize(text))['Flags'], 7).filter(([name]) => name)
187
191
  .map(([name, purpose, state, owner, added, removal, killSwitchTested]) => ({
188
192
  name, purpose, state, owner, added, removal, killSwitchTested,
189
193
  isOverdue: isDate(removal) && daysLeft(removal, now) < 0,
190
- untested: !/^(evet|✅|var|yes\b)/i.test(killSwitchTested ?? ''),
194
+ untested: !(isValue(killSwitchTested, 'yes') || /^(✅|var\b)/i.test(killSwitchTested ?? '')),
191
195
  }));
192
196
  }
193
197
 
194
198
  // ---------- unit economics ----------
195
199
  export function unitEconomics(text) {
196
- const e = labels(text);
200
+ const e = labels(normalize(text));
197
201
  const s = (k) => { const v = parseFloat(String(e[k] ?? '').replace(/[^\d.,-]/g, '').replace(',', '.')); return Number.isFinite(v) ? v : 0; };
198
- const price = s('Fiyat (kullanıcı başı aylık)');
199
- const commission = s('Ödeme komisyonu (%)');
200
- const fixedCost = s('Sabit aylık maliyet');
201
- const variableCost = s('Kullanıcı başı değişken maliyet (aylık)');
202
- const target = s('Hedef aktif kullanıcı');
203
- const freeShare = Math.min(99, s('Ücretsiz kullanıcı oranı (%)'));
202
+ const price = s('Price (per user, monthly)');
203
+ const commission = s('Payment fee (%)');
204
+ const fixedCost = s('Fixed monthly cost');
205
+ const variableCost = s('Variable cost per user (monthly)');
206
+ const target = s('Target active users');
207
+ const freeShare = Math.min(99, s('Free user share (%)'));
204
208
  if (!price && !fixedCost && !variableCost) return null;
205
209
  const margin = price * (1 - commission / 100) - variableCost;
206
210
  // Every paying user also carries the variable cost of the free users
@@ -210,7 +214,7 @@ export function unitEconomics(text) {
210
214
  const revenue = paying * price * (1 - commission / 100);
211
215
  const cost = fixedCost + target * variableCost;
212
216
  return {
213
- currency: e['Para birimi'] || '',
217
+ currency: e['Currency'] || '',
214
218
  price, margin, effectiveMargin,
215
219
  breakEven: effectiveMargin > 0 ? Math.ceil(fixedCost / effectiveMargin) : null,
216
220
  target, revenue, cost, profit: revenue - cost,
@@ -219,19 +223,24 @@ export function unitEconomics(text) {
219
223
  }
220
224
 
221
225
  // ---------- feedback and metrics ----------
226
+ const FEEDBACK_KINDS = ['bug', 'request', 'complaint', 'praise', 'question'];
227
+ const FEEDBACK_STATES = ['new', 'planned', 'done', 'rejected', 'merged'];
228
+ // 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;
222
230
  export function feedback(text) {
223
- const b = sections(text);
224
- const records = tableRows(b['Gelen kutusu'], 9).map(([no, date, source, user, summary, kind, theme, priority, state]) => ({ no, date, summary, kind, theme, priority, state }));
225
- const themes = tableRows(b['Temalar'], 3).map(([theme, count, feature, decision]) => ({ theme, count: parseInt(count, 10) || 0, feature, decision }))
231
+ const b = sections(normalize(text));
232
+ const records = tableRows(b['Inbox'], 9).map(([no, date, source, user, summary, kind, theme, priority, state]) =>
233
+ ({ no, date, summary, kind: inEnglish(kind, FEEDBACK_KINDS), theme, priority, state: inEnglish(state, FEEDBACK_STATES) }));
234
+ const themes = tableRows(b['Themes'], 3).map(([theme, count, feature, decision]) => ({ theme, count: parseInt(count, 10) || 0, feature, decision }))
226
235
  .sort((x, y) => y.count - x.count);
227
- return { records, themes, newCount: records.filter((k) => /yeni/i.test(k.state)).length };
236
+ return { records, themes, newCount: records.filter((k) => k.state === 'new').length };
228
237
  }
229
238
 
230
239
  // Comprehension check answers (F-22, narrator mode); null without rows so the card is left out
231
240
  export function learning(text) {
232
- // Only "doğru" / "yanlış" rows count; a skipped or malformed answer is not a wrong one
233
- const rows = tableRows(sections(text)['Kayıtlar'], 4).filter(([, , , result]) => /^(doğru|yanlış)/i.test(result))
234
- .map(([date, task, concept, result]) => ({ concept, right: /^doğru/i.test(result) }));
241
+ // 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'))
243
+ .map(([date, task, concept, result]) => ({ concept, right: isValue(result, 'right') }));
235
244
  if (!rows.length) return null;
236
245
  const missed = new Map();
237
246
  for (const r of rows) if (!r.right) missed.set(r.concept, (missed.get(r.concept) ?? 0) + 1);
@@ -240,17 +249,24 @@ export function learning(text) {
240
249
  }
241
250
 
242
251
  export function metrics(text) {
243
- return tableRows(sections(text)['Kayıtlar'], 9)
252
+ return tableRows(sections(normalize(text))['Records'], 9)
244
253
  .map(([period, taskCount, duration, limit, fixes, reverts, duplicates, critical, acceptance]) => ({ period, taskCount, duration, limit, fixes, reverts, duplicates, critical, acceptance }));
245
254
  }
246
255
 
247
256
  // ---------- finding reports ----------
248
- export const FINDING_KINDS = { tech: 'Teknik', business: 'İş', security: 'Güvenlik', ux: 'UX', perf: 'Performans', legal: 'Hukuk' };
257
+ 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
+ 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];
264
+ };
249
265
  export function findingReports(text) {
250
266
  const count = (x) => { const n = parseInt(String(x ?? '').trim().replace(/^[^\d-]+/, ''), 10); return Number.isFinite(n) ? n : null; };
251
- const rows = tableRows(sections(text)['Raporlar'], 7)
267
+ const rows = tableRows(sections(normalize(text))['Reports'], 7)
252
268
  .map(([date, kind, health, previous, findings, critical, report]) => ({
253
- date, kind: kind.toLowerCase(), health: count(health), previous: count(previous), findings: count(findings), critical: count(critical) ?? 0, report,
269
+ date, kind: findingKind(kind), health: count(health), previous: count(previous), findings: count(findings), critical: count(critical) ?? 0, report,
254
270
  }))
255
271
  .filter((r) => r.date && r.health != null);
256
272
  const latest = {};
@@ -283,7 +299,7 @@ export function producedAssets(root) {
283
299
 
284
300
  // ---------- Spend alarms (docs/operations/spend-alarms.md) ----------
285
301
  export function spendAlarms(text, now = Date.now()) {
286
- return tableRows(sections(text)['Alarmlar'], 6).filter(([service]) => service)
302
+ return tableRows(sections(normalize(text))['Alarms'], 6).filter(([service]) => service)
287
303
  .map(([service, kind, amount, thresholds, where, lastCheck]) => ({
288
304
  service, kind, amount, thresholds, where, lastCheck,
289
305
  unset: !/\d/.test(amount ?? ''),
@@ -300,17 +316,21 @@ export function councilMinutes(root) {
300
316
  const text = readText(path.join(folder, f));
301
317
  const a = {};
302
318
  for (const [, k, v] of text.matchAll(/\*\*([^*:]+):\*\*\s*([^·\n]*)/g)) a[k.trim()] ??= v.trim();
303
- const ranking = tableRows(sections(text)['3. Toplu sıralama'], 3).filter(([g]) => /^[A-Z]$/.test(g));
304
- const idsByLetter = Object.fromEntries([...(sections(text)['Kimlikler'] ?? '').matchAll(/([A-Z])\s*=\s*([^·\n]+)/g)].map((m) => [m[1], m[2].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
+ const b = sections(text);
322
+ 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];
305
325
  return {
306
326
  file: `docs/workflow/council/${f}`,
307
- topic: text.match(/^#\s+Konsey:\s*(.+)$/m)?.[1] ?? f.replace(/\.md$/, ''),
308
- date: a['Tarih'] ?? f.slice(0, 10),
309
- kind: a['Tür'] ?? '-',
310
- decision: a['Karar'] || a['Öneri'] || null,
311
- consensus: a['Uzlaşma']?.match(/\d+/) ? Number(a['Uzlaşma'].match(/\d+/)[0]) : null,
312
- disputed: Number(a['Tartışmalı'] ?? 0) || 0,
313
- eliminated: Number(a['Elenen'] ?? 0) || 0,
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,
331
+ consensus: consensus ? Number(consensus) : null,
332
+ disputed: Number(pick('Disputed', 'Tartışmalı') ?? 0) || 0,
333
+ eliminated: Number(pick('Eliminated', 'Elenen') ?? 0) || 0,
314
334
  ranking: ranking.map(([g, avg]) => ({ opinion: g, who: idsByLetter[g] ?? null, average: avg })),
315
335
  };
316
336
  }).sort((x, y) => y.date.localeCompare(x.date) || y.file.localeCompare(x.file));