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.
- package/.claude/agents/board-chair.md +1 -1
- package/.claude/agents/security-auditor.md +1 -1
- package/.claude/agents/test-writer.md +1 -1
- package/.claude/board/board-product.md +1 -1
- package/.claude/board/council.md +1 -1
- package/.claude/board/rules.md +6 -6
- package/.claude/commands/adopt.md +3 -3
- package/.claude/commands/ask-mode.md +4 -4
- package/.claude/commands/board.md +1 -1
- package/.claude/commands/build.md +1 -1
- package/.claude/commands/design.md +4 -4
- package/.claude/commands/findings-business.md +1 -1
- package/.claude/commands/findings-legal.md +1 -1
- package/.claude/commands/findings-perf.md +1 -1
- package/.claude/commands/findings-security.md +2 -2
- package/.claude/commands/findings-tech.md +1 -1
- package/.claude/commands/findings-ux.md +1 -1
- package/.claude/commands/fix-bug.md +2 -2
- package/.claude/commands/handoff.md +2 -2
- package/.claude/commands/kit-update.md +2 -2
- package/.claude/commands/qa.md +1 -1
- package/.claude/commands/release.md +2 -2
- package/.claude/commands/research.md +3 -3
- package/.claude/commands/retro.md +1 -1
- package/.claude/commands/rollback.md +3 -3
- package/.claude/commands/start.md +4 -4
- package/.claude/commands/tools.md +1 -1
- package/.claude/commands/user-feedback.md +1 -1
- package/.claude/findings/lenses/business.md +1 -1
- package/.claude/findings/protocol.md +2 -2
- package/.claude/hooks/budget.mjs +1 -1
- package/.claude/hooks/checkpoint.mjs +2 -2
- package/.claude/hooks/commit-gate.mjs +14 -15
- package/.claude/hooks/council.mjs +37 -52
- package/.claude/hooks/dashboard-data.mjs +34 -42
- package/.claude/hooks/dashboard.mjs +18 -20
- package/.claude/hooks/destructive-guard.mjs +2 -2
- package/.claude/hooks/flow.mjs +1 -1
- package/.claude/hooks/freeze-guard.mjs +2 -2
- package/.claude/hooks/gate.mjs +18 -11
- package/.claude/hooks/labels.mjs +92 -152
- package/.claude/hooks/lang.mjs +25 -9
- package/.claude/hooks/messages.mjs +496 -494
- package/.claude/hooks/notify.mjs +1 -1
- package/.claude/hooks/prefs.mjs +10 -18
- package/.claude/hooks/preview.mjs +1 -1
- package/.claude/hooks/progress.mjs +7 -9
- package/.claude/hooks/qa-report.mjs +1 -3
- package/.claude/hooks/session-start.mjs +2 -3
- package/.claude/hooks/status-read.mjs +5 -5
- package/.claude/hooks/statusline.mjs +4 -4
- package/.claude/hooks/tools.mjs +3 -7
- package/.claude/hooks/usage.mjs +2 -2
- package/.claude/i18n/tr.json +495 -0
- package/.claude/security-patterns.json +1 -1
- package/.claude/skills/logo-design/SKILL.md +2 -2
- package/.claude/skills/motion-video/SKILL.md +1 -1
- package/.claude/skills/source-research/SKILL.md +1 -1
- package/.claude/skills/ui-motion/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +1 -1
- package/README.md +2 -2
- package/README.tr.md +2 -2
- package/docs/architecture/overview.md +1 -1
- package/docs/decisions/log.md +1 -1
- package/docs/legal/privacy-kvkk.md +35 -35
- package/docs/operations/deploy-setup.md +1 -1
- package/docs/operations/feature-flags.md +1 -1
- package/docs/product/glossary.md +3 -3
- package/docs/product/launch.md +1 -1
- package/docs/product/prd.md +2 -2
- package/docs/product/roadmap.md +1 -1
- package/docs/product/specs/_template.md +1 -1
- package/docs/product/unit-economics.md +1 -1
- package/docs/quality/test-plan.md +5 -5
- package/docs/workflow/decision-protocol.md +1 -2
- package/docs/workflow/mcp-catalog.md +1 -1
- package/docs/workflow/skills.md +2 -2
- package/docs/workflow/usage-limits.md +1 -1
- package/kit/CHANGELOG.md +37 -0
- package/kit/VERSION +1 -1
- package/kit/guide.html +56 -56
- package/kit/guide.tr.html +10 -10
- package/kit/kit.mjs +7 -42
- package/kit/templates/addons/ai/.env.example.append +3 -3
- package/kit/templates/addons/ai/ai/evals/README.md +5 -5
- package/kit/templates/addons/ai/ai/evals/cases.jsonl +3 -3
- package/kit/templates/addons/ai/docs/ai/guide.md +1 -1
- package/kit/templates/addons/tr/docs/legal/turkiye.md +55 -55
- package/kit/templates/addons/tr/types/backend/.env.example.append +2 -2
- package/kit/templates/addons/tr/types/backend/app/shared/payments.py +1 -1
- package/kit/templates/addons/tr/types/backend/tests/security/test_payment_webhook.py +3 -3
- package/kit/templates/addons/tr/types/web-saas/.env.example.append +2 -2
- package/kit/templates/addons/tr/types/web-saas/.github/workflows/payment-webhook.yml +3 -3
- package/kit/templates/addons/tr/types/web-saas/src/lib/payments/webhook-signature.ts +1 -1
- package/kit/templates/addons/tr/types/web-saas/tests/security/payment-webhook.mjs +4 -4
- package/kit/templates/backend/.github/dependabot.yml +1 -1
- package/kit/templates/backend/.github/workflows/ci.yml +4 -4
- package/kit/templates/backend/.github/workflows/claude-ci-fix.yml +10 -10
- package/kit/templates/backend/.github/workflows/claude-maintenance.yml +1 -1
- package/kit/templates/backend/.github/workflows/live-monitoring.yml +2 -2
- package/kit/templates/backend/.pre-commit-config.yaml +1 -1
- package/kit/templates/backend/docs/architecture/stack.md +3 -3
- package/kit/templates/mobile/.github/dependabot.yml +1 -1
- package/kit/templates/mobile/.github/workflows/ci.yml +6 -6
- package/kit/templates/mobile/.github/workflows/claude-ci-fix.yml +10 -10
- package/kit/templates/mobile/.github/workflows/claude-maintenance.yml +1 -1
- package/kit/templates/mobile/docs/architecture/stack.md +4 -4
- package/kit/templates/mobile/docs/architecture/structure.md +1 -1
- package/kit/templates/mobile/docs/design/design-guide.md +1 -1
- package/kit/templates/web-saas/.github/dependabot.yml +1 -1
- package/kit/templates/web-saas/.github/workflows/ci.yml +6 -6
- package/kit/templates/web-saas/.github/workflows/claude-ci-fix.yml +10 -10
- package/kit/templates/web-saas/.github/workflows/claude-maintenance.yml +1 -1
- package/kit/templates/web-saas/.github/workflows/live-monitoring.yml +2 -2
- package/kit/templates/web-saas/docs/architecture/stack.md +4 -4
- package/kit/templates/web-saas/docs/design/design-guide.md +1 -1
- package/kit/tools/antigravity/.agents/hooks.json +5 -5
- 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"
|
|
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:
|
|
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 ?? '\
|
|
47
|
-
hashes.set(f, body ? digest(body) : '
|
|
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, '
|
|
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
|
|
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]*
|
|
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"
|
|
116
|
-
//
|
|
117
|
-
const SKIPPED = /^
|
|
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"
|
|
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] !== '
|
|
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
|
|
272
|
-
'3. If there is a
|
|
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
|
-
//
|
|
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*(
|
|
12
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:"
|
|
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(/
|
|
40
|
-
return [...new Set([...part.matchAll(
|
|
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(/
|
|
45
|
-
// Plain uppercase:
|
|
46
|
-
return Object.fromEntries([...part.matchAll(VOTE)].map((m) => [m[1].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(
|
|
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*\[(
|
|
56
|
-
const proposal = body.match(/^\s*
|
|
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
|
-
|
|
66
|
-
|
|
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 = {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(`**${
|
|
109
|
-
lines.push(`**${
|
|
110
|
-
if (s.invalid?.length) lines.push(
|
|
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(
|
|
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(
|
|
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(
|
|
123
|
-
const ask = (f, word) => (isCritical(f.severity) ? `${word} · ${
|
|
124
|
-
for (const f of s.findings) lines.push(`| ${f.id} | ${f.severity} | ${f.
|
|
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 = `## ${
|
|
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"
|
|
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 {
|
|
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
|
|
16
|
-
//
|
|
17
|
-
const CRITERION_ID = /(?<![A-Za-z])
|
|
18
|
-
const key = (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*(
|
|
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*(
|
|
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])
|
|
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
|
-
// ("
|
|
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
|
-
|
|
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(
|
|
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); "-", "
|
|
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(
|
|
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') ||
|
|
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(
|
|
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))]
|
|
225
|
+
const inEnglish = (v, keys) => VALUES[keys.find((k) => isValue(v, k))]?? v;
|
|
230
226
|
export function feedback(text) {
|
|
231
|
-
const b = sections(
|
|
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(
|
|
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(
|
|
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 =
|
|
262
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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'] ??
|
|
324
|
-
const consensus =
|
|
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+
|
|
328
|
-
date:
|
|
329
|
-
kind:
|
|
330
|
-
decision:
|
|
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(
|
|
333
|
-
eliminated: Number(
|
|
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 { /*
|
|
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
|
|