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.
- package/.claude/agents/board-chair.md +9 -9
- package/.claude/agents/code-reviewer.md +2 -2
- package/.claude/agents/council-advisor.md +2 -2
- package/.claude/agents/security-auditor.md +6 -6
- package/.claude/agents/test-writer.md +1 -1
- package/.claude/board/board-ai.md +6 -6
- package/.claude/board/board-api.md +6 -6
- package/.claude/board/board-architect.md +7 -7
- package/.claude/board/board-devops.md +6 -6
- package/.claude/board/board-mobile.md +6 -6
- package/.claude/board/board-performance.md +6 -6
- package/.claude/board/board-privacy.md +6 -6
- package/.claude/board/board-product.md +7 -7
- package/.claude/board/board-qa.md +7 -7
- package/.claude/board/board-ux.md +6 -6
- package/.claude/board/council.md +32 -32
- package/.claude/board/rules.md +8 -8
- package/.claude/commands/adopt.md +10 -10
- package/.claude/commands/ask-mode.md +6 -5
- package/.claude/commands/board.md +1 -1
- package/.claude/commands/build.md +8 -8
- package/.claude/commands/design.md +8 -8
- package/.claude/commands/findings-all.md +2 -2
- package/.claude/commands/findings-security.md +1 -1
- package/.claude/commands/fix-bug.md +2 -2
- package/.claude/commands/handoff.md +12 -12
- package/.claude/commands/language.md +1 -1
- package/.claude/commands/maintain.md +2 -2
- package/.claude/commands/plan-task.md +3 -3
- package/.claude/commands/qa.md +5 -5
- package/.claude/commands/release.md +3 -3
- package/.claude/commands/research.md +2 -2
- package/.claude/commands/retro.md +2 -2
- package/.claude/commands/review-changes.md +2 -2
- package/.claude/commands/rollback.md +1 -1
- package/.claude/commands/start.md +20 -20
- package/.claude/commands/tools.md +8 -6
- package/.claude/commands/user-feedback.md +3 -3
- package/.claude/commands/wrap-up.md +3 -3
- package/.claude/findings/protocol.md +2 -2
- package/.claude/findings/report-template.md +1 -1
- package/.claude/hooks/commit-gate.mjs +11 -9
- package/.claude/hooks/council.mjs +51 -24
- package/.claude/hooks/dashboard-data.mjs +73 -53
- package/.claude/hooks/dashboard.mjs +80 -71
- package/.claude/hooks/destructive-guard.mjs +1 -1
- package/.claude/hooks/freeze-guard.mjs +1 -1
- package/.claude/hooks/gate.mjs +14 -11
- package/.claude/hooks/labels.mjs +177 -0
- package/.claude/hooks/messages.mjs +8 -7
- package/.claude/hooks/notify.mjs +2 -1
- package/.claude/hooks/prefs.mjs +24 -10
- package/.claude/hooks/preview.mjs +6 -6
- package/.claude/hooks/progress.mjs +27 -15
- package/.claude/hooks/qa-report.mjs +12 -10
- package/.claude/hooks/session-start.mjs +9 -7
- package/.claude/hooks/status-read.mjs +22 -18
- package/.claude/hooks/statusline.mjs +10 -8
- package/.claude/hooks/tools.mjs +13 -8
- package/.claude/output-styles/{anlatici.md → narrator.md} +2 -2
- package/.claude/skills/logo-design/SKILL.md +2 -2
- package/.claude/skills/source-research/SKILL.md +1 -1
- package/.claude/skills/ui-motion/SKILL.md +2 -2
- package/AGENTS.md +12 -12
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/README.tr.md +3 -3
- package/docs/decisions/adr-template.md +1 -1
- package/docs/decisions/log.md +2 -2
- package/docs/findings/README.md +1 -1
- package/docs/operations/accounts.md +7 -7
- package/docs/operations/deploy-setup.md +3 -3
- package/docs/operations/feature-flags.md +2 -2
- package/docs/operations/maintenance.md +1 -1
- package/docs/operations/release-checklist.md +2 -2
- package/docs/operations/spend-alarms.md +2 -2
- package/docs/product/feedback.md +4 -4
- package/docs/product/launch.md +1 -1
- package/docs/product/prd.md +10 -10
- package/docs/product/roadmap.md +8 -8
- package/docs/product/specs/_template.md +8 -8
- package/docs/product/unit-economics.md +10 -10
- package/docs/quality/metrics.md +2 -2
- package/docs/quality/test-plan.md +5 -5
- package/docs/security/checklist.md +2 -2
- package/docs/workflow/board.md +5 -5
- package/docs/workflow/cost-log.md +1 -1
- package/docs/workflow/decision-protocol.md +18 -16
- package/docs/workflow/learning.md +2 -2
- package/docs/workflow/mcp-catalog.md +1 -1
- package/docs/workflow/models.md +1 -1
- package/docs/workflow/progress.md +14 -14
- package/docs/workflow/skills.md +9 -9
- package/docs/workflow/usage-limits.md +5 -5
- package/kit/CHANGELOG.md +28 -0
- package/kit/VERSION +1 -1
- package/kit/guide.html +21 -21
- package/kit/guide.tr.html +21 -21
- package/kit/kit.mjs +29 -0
- package/kit/project/README.md +1 -1
- 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/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 +1 -1
- package/kit/templates/backend/.pre-commit-config.yaml +1 -1
- package/kit/templates/backend/docs/architecture/stack.md +7 -7
- package/kit/templates/backend/docs/architecture/structure.md +2 -2
- package/kit/templates/mobile/.github/workflows/ci.yml +4 -4
- 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 +5 -5
- package/kit/templates/mobile/docs/architecture/structure.md +1 -1
- package/kit/templates/mobile/docs/design/design-guide.md +1 -1
- package/kit/templates/mobile/tests/security/rls.mjs +4 -4
- package/kit/templates/web-saas/.github/workflows/ci.yml +4 -4
- 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 +1 -1
- package/kit/templates/web-saas/docs/architecture/stack.md +12 -12
- package/kit/templates/web-saas/docs/architecture/structure.md +2 -2
- package/kit/templates/web-saas/docs/design/design-guide.md +1 -1
- package/kit/templates/web-saas/tests/security/rls.mjs +4 -4
- package/package.json +1 -1
|
@@ -7,9 +7,11 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { readStatus, readText, sections, findSection, tally, tableRows } from './status-read.mjs';
|
|
10
|
+
import { verdict, isValue } from './labels.mjs';
|
|
10
11
|
import { readLimits, summary, level, THRESHOLDS } from './usage.mjs';
|
|
11
12
|
import { packageStatus, commandMode } from './tools.mjs';
|
|
12
13
|
import { readModels, modelsFile, modelsNote, roleLabel } from './models.mjs';
|
|
14
|
+
import { OLD_SIZES } from './progress.mjs';
|
|
13
15
|
import { readLang, t } from './lang.mjs';
|
|
14
16
|
import { acceptanceStatus, accounts, flags, unitEconomics, feedback, learning, qaSummary, metrics, githubStatus, readContext, findingReports, FINDING_KINDS, producedAssets, costSummary, councilMinutes, spendAlarms, envWarnings, repeatedBugClasses } from './dashboard-data.mjs';
|
|
15
17
|
|
|
@@ -21,7 +23,7 @@ const root = path.resolve(args.find((a) => !a.startsWith('--')) ?? process.env.C
|
|
|
21
23
|
const file = (f) => path.join(root, f);
|
|
22
24
|
// Every visible text comes from messages.mjs in the project language (L = t with this project's root)
|
|
23
25
|
const L = (key, vars) => t(root, key, vars);
|
|
24
|
-
const dateKind = (k) => (k === '
|
|
26
|
+
const dateKind = (k) => (k === 'expiry' ? L('dashboard.dateExpiry') : L('dashboard.dateKeyRotation'));
|
|
25
27
|
const kindName = (k) => (Object.hasOwn(FINDING_KINDS, k) ? L(`dashboard.findingKind.${k}`) : k);
|
|
26
28
|
// The dashboard is made in the user's language: none before /start or /adopt asks it
|
|
27
29
|
const lang = readLang(root);
|
|
@@ -63,21 +65,21 @@ const firstSentence = (b) => (b ?? '').split('\n').map((s) => s.trim()).find((s)
|
|
|
63
65
|
const mvp = findSection(prd, '5. MVP').split(/^### /m).slice(1).map((p) => {
|
|
64
66
|
const [title, ...body] = p.split('\n');
|
|
65
67
|
const text = body.join('\n');
|
|
66
|
-
return { title: title.trim(), priority: text.match(
|
|
68
|
+
return { title: title.trim(), priority: text.match(/\*\*Priority:\*\*\s*(.+)/)?.[1]?.trim() ?? '', ...tally(text) };
|
|
67
69
|
}).filter((o) => !o.title.includes('[') && o.title !== '...');
|
|
68
|
-
const outOfScope = findSection(prd, '6.
|
|
70
|
+
const outOfScope = findSection(prd, '6. Out of scope').split('\n').map((s) => s.trim()).filter((s) => /^- \S/.test(s) && !s.includes('[')).map((s) => s.slice(2));
|
|
69
71
|
|
|
70
|
-
const stack = tableRows(sections(readText(file('docs/architecture/stack.md')))['
|
|
71
|
-
const criticalFlows = tableRows(findSection(sections(readText(file('docs/quality/test-plan.md'))), '1.
|
|
72
|
+
const stack = tableRows(sections(readText(file('docs/architecture/stack.md')))['Chosen technologies'], 2).map(([layer, choice]) => ({ layer, choice }));
|
|
73
|
+
const criticalFlows = tableRows(findSection(sections(readText(file('docs/quality/test-plan.md'))), '1. Critical'), 6)
|
|
72
74
|
.map(([no, feed, , kind, fileName, state]) => ({ no, feed, kind, fileName, state }));
|
|
73
75
|
const cost = tableRows(readText(file('docs/workflow/cost-log.md')), 6)
|
|
74
76
|
.map(([date, job, size, startPct, endPct, diff]) => ({ date, job, size, diff: parseFloat(String(diff).replace('%', '').replace(',', '.')) }));
|
|
75
|
-
const maintenance = tableRows(sections(readText(file('docs/operations/maintenance.md')))['
|
|
77
|
+
const maintenance = tableRows(sections(readText(file('docs/operations/maintenance.md')))['Maintenance log'], 4)
|
|
76
78
|
.map(([date, kind, findings, work]) => ({ date, kind, findings, work }));
|
|
77
|
-
const unreleased = findSection(sections(readText(file('CHANGELOG.md'))), '[
|
|
79
|
+
const unreleased = findSection(sections(readText(file('CHANGELOG.md'))), '[Unreleased]')
|
|
78
80
|
.split('\n').map((s) => s.trim()).filter((s) => /^- \S/.test(s)).map((s) => s.slice(2));
|
|
79
81
|
const lastRetro = Object.keys(sections(readText(file('docs/quality/retros.md')))).filter((b) => /^\d{4}-/.test(b)).pop() ?? '';
|
|
80
|
-
const designDirection = tableRows(findSection(sections(readText(file('docs/design/design-guide.md'))), '1.
|
|
82
|
+
const designDirection = tableRows(findSection(sections(readText(file('docs/design/design-guide.md'))), '1. Chosen'), 2)
|
|
81
83
|
.filter(([, choice]) => choice && !choice.startsWith('[')).map(([area, choice]) => ({ area, choice }));
|
|
82
84
|
|
|
83
85
|
const assets = producedAssets(root);
|
|
@@ -114,8 +116,8 @@ const findings = findingReports(readText(file('docs/findings/README.md')));
|
|
|
114
116
|
const alerts = [];
|
|
115
117
|
const addAlert = (sev, text, action = '') => alerts.push({ sev, text, action });
|
|
116
118
|
if (d.kitPending) addAlert('info', L('dashboard.alert.kitPending'), L('dashboard.act.startIdea'));
|
|
117
|
-
if (d.handoffPending) addAlert('warn', L('dashboard.alert.handoffPending'), '/handoff
|
|
118
|
-
const lastReject = [...d.board.meetings].reverse().find((t) =>
|
|
119
|
+
if (d.handoffPending) addAlert('warn', L('dashboard.alert.handoffPending'), '/handoff take');
|
|
120
|
+
const lastReject = [...d.board.meetings].reverse().find((t) => verdict(t.result) === 'reject');
|
|
119
121
|
if (lastReject && !lastReject.decision) addAlert('critical', L('dashboard.alert.boardReject', { kind: lastReject.kind, scope: lastReject.scope, critical: lastReject.critical || L('dashboard.criticalFinding') }), L('dashboard.act.askClaudeFix'));
|
|
120
122
|
const limitLevel = level(limit);
|
|
121
123
|
const limitShort = limit ? [[L('dashboard.limit5h'), limit.five_hour], [L('dashboard.limitWeeklyLower'), limit.seven_day]].filter(([, p]) => p)
|
|
@@ -128,9 +130,9 @@ for (const s of d.issues.slice(0, 5)) addAlert('warn', L('dashboard.alert.openIs
|
|
|
128
130
|
for (const r of repeatedBugClasses(root)) addAlert('info', L('dashboard.alert.repeatedBug', { count: r.specs.length, cls: r.cls, specs: r.specs.join(', ') }), '/retro');
|
|
129
131
|
if (git?.changed.length > 15) addAlert('info', L('dashboard.alert.uncommitted', { count: git.changed.length }), L('dashboard.act.commitOrWrapUp'));
|
|
130
132
|
if (git?.progressStale >= 3) addAlert('info', L('dashboard.alert.progressStale', { count: git.progressStale }), '/wrap-up');
|
|
131
|
-
const off = (a['
|
|
132
|
-
if (off &&
|
|
133
|
-
if (d.installed && d.board.state
|
|
133
|
+
const off = (a['Question mode'] ?? '').match(/(?:kapalı konular|closed topics):\s*(.+)$/i)?.[1];
|
|
134
|
+
if (off && !isValue(off, 'none')) addAlert('info', L('dashboard.alert.closedTopics', { topics: off }), L('dashboard.act.askModeOpen'));
|
|
135
|
+
if (d.installed && isValue(d.board.state, 'notSetUp') && !isValue(a['Active phase'], 'setup')) addAlert('info', L('dashboard.alert.boardNotSetUp'), '/board setup');
|
|
134
136
|
if (github?.liveMonitoring?.length) addAlert('critical', L('dashboard.alert.liveMonitoring', { title: github.liveMonitoring[0].title }), L('dashboard.act.fixLive', { number: github.liveMonitoring[0].number }));
|
|
135
137
|
else if (github?.liveRun?.conclusion === 'failure') addAlert('critical', L('dashboard.alert.liveRunFailed'), L('dashboard.act.fixLiveShort'));
|
|
136
138
|
const lastMain = github?.runs?.find((c) => /^(main|master)$/.test(c.headBranch) && c.workflowName === 'CI');
|
|
@@ -150,8 +152,8 @@ if (fb.newCount) addAlert('info', L('dashboard.alert.newFeedback', { count: fb.n
|
|
|
150
152
|
const enabledPackages = (() => { try { return packageStatus(root).filter((p) => p.enabled).map((p) => p.name); } catch { return []; } })();
|
|
151
153
|
if (enabledPackages.length) addAlert('info', L('dashboard.alert.packagesOn', { names: enabledPackages.join(', ') }), L('dashboard.act.packagesOff'));
|
|
152
154
|
const cmdMode = (() => { try { return commandMode(root).mode; } catch { return 'light'; } })();
|
|
153
|
-
if (cmdMode !== 'full' &&
|
|
154
|
-
else if (cmdMode !== 'light' &&
|
|
155
|
+
if (cmdMode !== 'full' && isValue(a['Profile'], 'full')) addAlert('info', L('dashboard.alert.cmdListLight'), L('dashboard.act.cmdFull'));
|
|
156
|
+
else if (cmdMode !== 'light' && !isValue(a['Profile'], 'full')) addAlert('info', L('dashboard.alert.cmdListHeavy', { mode: cmdMode === 'full' ? L('dashboard.cmdModeFull') : L('dashboard.cmdModeMixed') }), L('dashboard.act.cmdLight'));
|
|
155
157
|
if (assets.codeMapDays > 30) addAlert('info', L('dashboard.alert.codeMapStale', { days: assets.codeMapDays }), L('dashboard.act.understand'));
|
|
156
158
|
if (context?.percent >= 70 && Date.now() - context.readAt < 3600e3) addAlert('warn', L('dashboard.alert.contextFull', { percent: context.percent }), L('dashboard.act.wrapUpClear'));
|
|
157
159
|
for (const r of Object.values(findings.latest)) if (r.critical > 0) addAlert('warn', L('dashboard.alert.findingsCritical', { kind: kindName(r.kind), critical: r.critical, health: r.health }), `docs/findings/${r.kind}/latest.md`);
|
|
@@ -174,17 +176,17 @@ const fixRatio = (kindCounts.feat ?? 0) + (kindCounts.fix ?? 0) ? Math.round(((k
|
|
|
174
176
|
|
|
175
177
|
const feed = [
|
|
176
178
|
...(git?.commits ?? []).slice(0, 60).map((c) => ({ time: c.date, kind: 'commit', title: c.message, extra: c.hash })),
|
|
177
|
-
...d.decisions.map((k) => ({ time: k.date, kind: '
|
|
178
|
-
...d.board.meetings.map((t) => ({ time: t.date, kind: '
|
|
179
|
-
...maintenance.map((b) => ({ time: b.date, kind: '
|
|
179
|
+
...d.decisions.map((k) => ({ time: k.date, kind: 'decision', title: `${k.question} → ${k.chosen}`, extra: `${k.topic} · ${k.decidedBy}` })),
|
|
180
|
+
...d.board.meetings.map((t) => ({ time: t.date, kind: 'board', title: `${t.kind}: ${t.scope} → ${t.result}`, extra: t.attendees })),
|
|
181
|
+
...maintenance.map((b) => ({ time: b.date, kind: 'maintenance', title: `${b.kind}: ${b.findings}`, extra: b.work })),
|
|
180
182
|
].filter((x) => x.time && !Number.isNaN(toDate(x.time).getTime()))
|
|
181
183
|
.sort((x, y) => toDate(y.time) - toDate(x.time)).slice(0, 50);
|
|
182
184
|
|
|
183
|
-
const autoDecisions = d.decisions.filter((k) =>
|
|
184
|
-
const rejectCount = d.board.meetings.filter((t) =>
|
|
185
|
+
const autoDecisions = d.decisions.filter((k) => isValue(k.decidedBy, 'automatic')).length;
|
|
186
|
+
const rejectCount = d.board.meetings.filter((t) => verdict(t.result) === 'reject').length;
|
|
185
187
|
const mvpCriteria = mvp.reduce((t, o) => ({ done: t.done + o.done, total: t.total + o.total }), { done: 0, total: 0 });
|
|
186
188
|
const avgCost = Object.entries(cost.reduce((m, r) => {
|
|
187
|
-
if (!Number.isNaN(r.diff)) (m[r.size] ??= []).push(r.diff);
|
|
189
|
+
if (!Number.isNaN(r.diff)) (m[OLD_SIZES[r.size] ?? r.size] ??= []).push(r.diff);
|
|
188
190
|
return m;
|
|
189
191
|
}, Object.create(null))).map(([size, l]) => ({ size, avg: l.reduce((t, x) => t + x, 0) / l.length, n: l.length }));
|
|
190
192
|
|
|
@@ -195,26 +197,33 @@ const percent = (b, t) => (t ? Math.round((b / t) * 100) : 0);
|
|
|
195
197
|
const progressBar = (b, t, cls = '') => `<div class="bar ${cls}" role="img" aria-label="${b}/${t}"><span style="width:${percent(b, t)}%"></span></div>`;
|
|
196
198
|
const empty = (text, action = '') => `<p class="empty">${md(text)}${action ? ` <code>${e(action)}</code>` : ''}</p>`;
|
|
197
199
|
const SEV = { critical: ['⛔', 'dashboard.sevCritical'], warn: ['⚠', 'dashboard.sevWarn'], info: ['ℹ', 'dashboard.sevInfo'], good: ['✓', 'dashboard.sevGood'] };
|
|
198
|
-
const FEED_KEYS = { commit: 'dashboard.feedCommit',
|
|
200
|
+
const FEED_KEYS = { commit: 'dashboard.feedCommit', decision: 'dashboard.feedDecision', board: 'dashboard.feedBoard', maintenance: 'dashboard.feedMaintenance' };
|
|
199
201
|
const feedLabel = (k) => (Object.hasOwn(FEED_KEYS, k) ? L(FEED_KEYS[k]) : k);
|
|
200
|
-
const onOff = (v) => (v
|
|
202
|
+
const onOff = (v) => (isValue(v, 'on') ? L('dashboard.on') : isValue(v, 'off') ? L('dashboard.off') : v);
|
|
201
203
|
// A text for the page's own script: a single-quoted JS string, no tag can end the script block
|
|
202
204
|
const jsStr = (key) => `'${L(key).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/</g, '\\u003c')}'`;
|
|
205
|
+
// A board verdict in either language; an option name (decision council) or an unreadable result is neutral
|
|
206
|
+
const verdictBadge = (k) => badge({ approve: 'good', conditional: 'warn', reject: 'critical' }[verdict(k)] ?? 'info', k);
|
|
203
207
|
const badge = (sev, caption) => `<span class="badge ${sev}"><span class="ic" aria-hidden="true">${SEV[sev][0]}</span> ${e(caption ?? L(SEV[sev][1]))}</span>`;
|
|
204
208
|
const fmtDate = (t) => { const z = toDate(t); return Number.isNaN(z.getTime()) ? e(t) : z.toLocaleString(loc, { day: '2-digit', month: 'short', hour: t.length > 10 ? '2-digit' : undefined, minute: t.length > 10 ? '2-digit' : undefined }); };
|
|
205
209
|
const limitSeverity = (p, k) => (p == null ? 'info' : p >= THRESHOLDS[k].red ? 'critical' : p >= THRESHOLDS[k].yellow ? 'warn' : 'good');
|
|
206
|
-
const TOPICS = ['
|
|
210
|
+
const TOPICS = ['product', 'tech', 'design', 'content', 'data', 'security', 'cost'];
|
|
211
|
+
// Decision log topics before 3.48, in the same order; they count under the English topic
|
|
212
|
+
const TOPICS_TR = ['urun', 'teknik', 'tasarim', 'icerik', 'veri', 'guvenlik', 'maliyet'];
|
|
213
|
+
const topicKey = (topic = '') => { const k = topic.trim().toLowerCase(); return TOPICS[TOPICS_TR.indexOf(k)] ?? k; };
|
|
207
214
|
|
|
208
215
|
// The command to paste into Claude: "`/build G-013` …" or "/wrap-up ve /clear" → first command, without its <placeholder> or note
|
|
209
216
|
const cmdOf = (s = '') => { const m = String(s).match(/`(\/[^`\n]+)`/) ?? String(s).match(/^(\/.*)$/); return m ? m[1].split(/ ve | ile | and | to |\s*\(/)[0].replace(/\s*[[<].*$/, '').trim() : ''; };
|
|
210
|
-
const copyBtn = (s) => { const c = cmdOf(s); return c ? ` <button class="
|
|
211
|
-
const alertRow = (x) => `<li class="${x.sev}"><span class="ic" aria-hidden="true">${SEV[x.sev][0]}</span><span><span class="sr-only">${L(SEV[x.sev][1])}: </span>${md(x.text)}</span>${x.action ? `<span class="
|
|
217
|
+
const copyBtn = (s) => { const c = cmdOf(s); return c ? ` <button class="copy-cmd" type="button" data-cmd="${e(c)}" aria-label="${e(L('dashboard.copyAria', { command: c }))}">${e(L('dashboard.copy'))}</button>` : ''; };
|
|
218
|
+
const alertRow = (x) => `<li class="${x.sev}"><span class="ic" aria-hidden="true">${SEV[x.sev][0]}</span><span><span class="sr-only">${L(SEV[x.sev][1])}: </span>${md(x.text)}</span>${x.action ? `<span class="action">→ ${md(x.action)}${copyBtn(x.action)}</span>` : ''}</li>`;
|
|
212
219
|
const kpi = (label, value, sub = '', bar = '') => `<div class="kpi"><div class="kpi-l">${e(label)}</div><div class="kpi-v">${value}</div>${bar}<div class="kpi-s">${sub}</div></div>`;
|
|
213
220
|
|
|
214
221
|
// ---------- sections ----------
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
222
|
+
// A pre-3.48 value says "Faz 1"; the roadmap headings are read as "Phase 1"
|
|
223
|
+
const activePhase = (a['Active phase'] ?? '').replace(/^faz\b/i, 'Phase').replace(/^başlangıç(?![\p{L}\p{N}])/iu, 'Setup');
|
|
224
|
+
const activeIndex = Math.max(0, [{ name: 'Setup' }, ...d.phases].findIndex((f) => activePhase.toLowerCase().startsWith(f.name.toLowerCase().split(' ').slice(0, 2).join(' '))));
|
|
225
|
+
const start = (a['Setup step'] ?? '0/9').split('/').map(Number);
|
|
226
|
+
const stageStrip = `<ol class="steps">${[{ name: 'Setup', done: start[0] || 0, total: start[1] || 9 }, ...d.phases].map((f, i) =>
|
|
218
227
|
`<li class="step${i === activeIndex ? ' now' : ''}${f.total && f.done === f.total ? ' done' : ''}"><span class="step-n">${i === 0 ? e(L('dashboard.stageStartLetter')) : i - 1}</span><div class="step-b"><b>${e(i === 0 ? L('dashboard.stageStart') : f.name.replace(/\s*\(.*\)$/, ''))}</b>${progressBar(f.done, f.total)}<span class="muted num">${f.done}/${f.total}</span></div></li>`).join('')}</ol>`;
|
|
219
228
|
|
|
220
229
|
const overview = `
|
|
@@ -226,36 +235,36 @@ const overview = `
|
|
|
226
235
|
<section class="card"><h3>${L('dashboard.stages')}</h3>${stageStrip}</section>`;
|
|
227
236
|
|
|
228
237
|
function feedRow(x) {
|
|
229
|
-
return `<li class="feed-i t-${x.kind
|
|
238
|
+
return `<li class="feed-i t-${x.kind}"><span class="ft">${e(feedLabel(x.kind))}</span><div><div>${md(x.title)}</div><div class="muted small">${fmtDate(x.time)}${x.extra ? ' · ' + e(x.extra) : ''}</div></div></li>`;
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
const roadmapSection = d.phases.length ? d.phases.map((f) => `
|
|
233
|
-
<details class="card
|
|
234
|
-
<summary><b>${e(f.name)}</b><span class="
|
|
235
|
-
<ul class="tasks">${f.tasks.map((g) => `<li class="${g.done ? 'ok' : ''}${(a['
|
|
242
|
+
<details class="card phase" ${f.name.toLowerCase().startsWith((activePhase || '#').toLowerCase().split(' ').slice(0, 2).join(' ')) ? 'open' : ''}>
|
|
243
|
+
<summary><b>${e(f.name)}</b><span class="phase-bar">${progressBar(f.done, f.total)}<span class="num muted">${f.done}/${f.total}</span></span></summary>
|
|
244
|
+
<ul class="tasks">${f.tasks.map((g) => `<li class="${g.done ? 'ok' : ''}${(a['Active task'] && g.text.startsWith(a['Active task'])) ? ' active' : ''}"><span class="chk" aria-hidden="true">${g.done ? '✓' : ''}</span><span>${md(g.text)}</span></li>`).join('')}</ul>
|
|
236
245
|
</details>`).join('') : empty(L('dashboard.noRoadmap'));
|
|
237
246
|
|
|
238
247
|
const decisionTable = d.decisions.length ? `
|
|
239
|
-
<div class="filters" role="group" aria-label="${e(L('dashboard.topicFilter'))}"><button class="chip on" data-k="
|
|
240
|
-
<div class="tw"><table id="
|
|
241
|
-
${[...d.decisions].reverse().map((k) => `<tr data-k="${e(k.topic)}" data-v="${
|
|
248
|
+
<div class="filters" role="group" aria-label="${e(L('dashboard.topicFilter'))}"><button class="chip on" data-k="all">${L('dashboard.all')} (${d.decisions.length})</button>${TOPICS.filter((k) => d.decisions.some((x) => topicKey(x.topic) === k)).map((k) => `<button class="chip" data-k="${k}">${k} (${d.decisions.filter((x) => topicKey(x.topic) === k).length})</button>`).join('')}<button class="chip" data-k="automatic">${L('dashboard.auto')} (${autoDecisions})</button><button class="chip" data-k="one-way">${L('dashboard.oneWay')} (${d.decisions.filter((k) => isValue(k.door, 'oneWay')).length})</button></div>
|
|
249
|
+
<div class="tw"><table id="decision-table"><thead><tr><th>#</th><th>${L('dashboard.th.date')}</th><th>${L('dashboard.th.stage')}</th><th>${L('dashboard.th.topic')}</th><th>${L('dashboard.th.question')}</th><th>${L('dashboard.th.chosen')}</th><th>${L('dashboard.th.door')}</th><th>${L('dashboard.th.decidedBy')}</th></tr></thead><tbody>
|
|
250
|
+
${[...d.decisions].reverse().map((k) => `<tr data-k="${e(topicKey(k.topic))}" data-v="${isValue(k.decidedBy, 'automatic') ? 'automatic' : ''}" data-door="${isValue(k.door, 'oneWay') ? 'one-way' : ''}"><td class="num">${e(k.no)}</td><td class="num nw">${e(k.date)}</td><td>${e(k.stage)}</td><td><span class="tag">${e(k.topic)}</span></td><td>${md(k.question)}</td><td><b>${md(k.chosen)}</b></td><td>${isValue(k.door, 'oneWay') ? badge('warn', L('dashboard.oneWay')) : k.door ? `<span class="muted small">${e(k.door)}</span>` : ''}</td><td>${isValue(k.decidedBy, 'automatic') ? badge('warn', L('dashboard.auto')) : badge('good', L('dashboard.user'))}</td></tr>`).join('')}
|
|
242
251
|
</tbody></table></div>` : empty(L('dashboard.noDecisions'));
|
|
243
252
|
|
|
244
253
|
const boardSection = `
|
|
245
254
|
<div class="grid2">
|
|
246
|
-
<section class="card"><h3>${L('dashboard.members')} · ${e(d.board.state
|
|
255
|
+
<section class="card"><h3>${L('dashboard.members')} · ${e(isValue(d.board.state, 'setUp') ? L('dashboard.boardSetUp') : isValue(d.board.state, 'notSetUp') ? L('dashboard.notSetUp') : d.board.state)}</h3>${d.board.members.length ? `<ul class="members">${d.board.members.map((u) => `<li><b>${e(u.member)}</b><span class="muted small">${e(u.model)} · ${e(u.focus)}</span></li>`).join('')}</ul>` : empty(L('dashboard.boardNotSetUp'), '/board setup')}
|
|
247
256
|
<p class="muted small">${L('dashboard.commitReview', { value: e(onOff(d.board.commitReview)) })}</p></section>
|
|
248
|
-
<section class="card"><h3>${L('dashboard.summary')}</h3><div class="kpis mini">${kpi(L('dashboard.kpiMeetings'), d.board.meetings.length)}${kpi('
|
|
257
|
+
<section class="card"><h3>${L('dashboard.summary')}</h3><div class="kpis mini">${kpi(L('dashboard.kpiMeetings'), d.board.meetings.length)}${kpi(L('dashboard.kpiRejects'), rejectCount, rejectCount ? badge('critical', L('dashboard.criticalFinding')) : '')}${kpi(L('dashboard.kpiApproval'), d.board.meetings.length ? `%${percent(d.board.meetings.filter((t) => verdict(t.result) === 'approve').length, d.board.meetings.length)}` : '-')}</div></section>
|
|
249
258
|
</div>
|
|
250
259
|
<section class="card"><h3>${L('dashboard.councilHeading')}</h3>${councils.length ? (() => {
|
|
251
260
|
const latest = councils[0];
|
|
252
|
-
const decisionBadge = (k) => (!k ? badge('info', L('dashboard.noDecision')) :
|
|
261
|
+
const decisionBadge = (k) => (!k ? badge('info', L('dashboard.noDecision')) : verdictBadge(k));
|
|
253
262
|
return `<h4>${L('dashboard.latestCouncil', { topic: md(latest.topic) })}</h4><div class="kpis mini">${kpi(L('dashboard.th.decision'), decisionBadge(latest.decision))}${kpi(L('dashboard.th.consensus'), latest.consensus == null ? '-' : `%${latest.consensus}`, latest.consensus != null && latest.consensus < 60 ? badge('warn', L('dashboard.low')) : '')}${kpi(L('dashboard.kpiDisputed'), latest.disputed)}${kpi(L('dashboard.kpiEliminated'), latest.eliminated, L('dashboard.byCrossVote'))}</div>
|
|
254
263
|
${latest.ranking.length ? `<h4>${L('dashboard.overallRanking')}</h4><div class="rows">${latest.ranking.map((r, i) => `<div class="row"><span><b>${i + 1}.</b> ${L('dashboard.opinion', { letter: e(r.opinion) })}${r.who ? ` <span class="muted small">${e(r.who)}</span>` : ''}</span><span class="num muted">${L('dashboard.avg', { average: e(r.average) })}</span></div>`).join('')}</div>` : ''}
|
|
255
264
|
<div class="tw"><table><thead><tr><th>${L('dashboard.th.date')}</th><th>${L('dashboard.th.kind')}</th><th>${L('dashboard.th.topic')}</th><th>${L('dashboard.th.decision')}</th><th>${L('dashboard.th.consensus')}</th><th>${L('dashboard.th.minutes')}</th></tr></thead><tbody>${councils.slice(0, 10).map((k) => `<tr><td class="num nw">${e(k.date)}</td><td>${e(k.kind)}</td><td>${md(k.topic)}</td><td>${decisionBadge(k.decision)}</td><td class="num">${k.consensus == null ? '-' : `%${k.consensus}`}</td><td><a href="${k.file.split('/').map(encodeURIComponent).join('/')}" target="_blank" rel="noopener">${L('dashboard.open')}</a></td></tr>`).join('')}</tbody></table></div>`;
|
|
256
|
-
})() : empty(L('dashboard.noCouncil'), '/board council <
|
|
265
|
+
})() : empty(L('dashboard.noCouncil'), '/board council <question>')}</section>
|
|
257
266
|
<section class="card"><h3>${L('dashboard.meetingLog')}</h3>${d.board.meetings.length ? `<div class="tw"><table><thead><tr><th>#</th><th>${L('dashboard.th.date')}</th><th>${L('dashboard.th.kind')}</th><th>${L('dashboard.th.scope')}</th><th>${L('dashboard.th.attendees')}</th><th>${L('dashboard.th.result')}</th><th>${L('dashboard.th.critical')}</th><th>${L('dashboard.th.userDecision')}</th></tr></thead><tbody>
|
|
258
|
-
${[...d.board.meetings].reverse().map((t) => `<tr><td class="num">${e(t.no)}</td><td class="num nw">${e(t.date)}</td><td>${e(t.kind)}</td><td>${md(t.scope)}</td><td>${e(t.attendees)}</td><td>${
|
|
267
|
+
${[...d.board.meetings].reverse().map((t) => `<tr><td class="num">${e(t.no)}</td><td class="num nw">${e(t.date)}</td><td>${e(t.kind)}</td><td>${md(t.scope)}</td><td>${e(t.attendees)}</td><td>${verdictBadge(t.result)}</td><td>${md(t.critical)}</td><td>${md(t.decision)}</td></tr>`).join('')}
|
|
259
268
|
</tbody></table></div>` : empty(L('dashboard.noMeetings'))}</section>`;
|
|
260
269
|
|
|
261
270
|
const quality = `
|
|
@@ -270,7 +279,7 @@ ${qa ? `<section class="card"><h3>${L('dashboard.lastQa', { name: e(qa.name) })}
|
|
|
270
279
|
<section class="card"><h3>${L('dashboard.acceptanceTests')}</h3>${acceptance.total ? `<div class="kpis mini">${kpi(L('dashboard.th.criterion'), acceptance.total)}${kpi(L('dashboard.kpiTested'), `${acceptance.tested}/${acceptance.total}`, '', progressBar(acceptance.tested, acceptance.total))}${kpi(L('dashboard.kpiPassing'), `${acceptance.passing}/${acceptance.total}`, acceptance.reportTime ? L('dashboard.reportTime', { when: new Date(acceptance.reportTime).toLocaleString(loc, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) }) : L('dashboard.noJunit'), progressBar(acceptance.passing, acceptance.total, 'good'))}</div>` : empty(L('dashboard.noAcceptanceIds'))}</section>
|
|
271
280
|
<section class="card"><h3>${L('dashboard.metricsMonthly')}</h3>${metricRecords.length ? `<div class="tw"><table><thead><tr><th>${L('dashboard.th.period')}</th><th>${L('dashboard.th.task')}</th><th>${L('dashboard.th.avgMinutes')}</th><th>${L('dashboard.th.avgLimit')}</th><th>${L('dashboard.th.fixes')}</th><th>${L('dashboard.th.reverts')}</th><th>${L('dashboard.th.duplicateCode')}</th><th>${L('dashboard.th.openCritical')}</th><th>${L('dashboard.th.acceptance')}</th></tr></thead><tbody>${[...metricRecords].reverse().slice(0, 8).map((m) => `<tr><td class="nw">${e(m.period)}</td><td class="num">${e(m.taskCount)}</td><td class="num">${e(m.duration)}</td><td class="num">${e(m.limit)}</td><td class="num">${e(m.fixes)}</td><td class="num">${e(m.reverts)}</td><td class="num">${e(m.duplicates)}</td><td class="num">${e(m.critical)}</td><td class="num">${e(m.acceptance)}</td></tr>`).join('')}</tbody></table></div>` : empty(L('dashboard.noMetrics'))}</section>
|
|
272
281
|
<section class="card"><h3>${L('dashboard.criticalFlowTests')}</h3>${criticalFlows.length ? `<div class="tw"><table><thead><tr><th>#</th><th>${L('dashboard.th.flow')}</th><th>${L('dashboard.th.testKind')}</th><th>${L('dashboard.th.file')}</th><th>${L('dashboard.status')}</th></tr></thead><tbody>${criticalFlows.map((k) => `<tr><td class="num">${e(k.no)}</td><td>${md(k.feed)}</td><td>${e(k.kind)}</td><td>${md(k.fileName)}</td><td>${/✅|yeşil|var/i.test(k.state) ? badge('good', L('dashboard.present')) : badge('warn', L('dashboard.missing'))}</td></tr>`).join('')}</tbody></table></div>` : empty(L('dashboard.noTestPlan'))}</section>
|
|
273
|
-
<section class="card"><h3>${L('dashboard.maintenanceLog')}</h3>${maintenance.length ? `<ul class="feed compact">${[...maintenance].reverse().slice(0, 8).map((b) => feedRow({ time: b.date, kind: '
|
|
282
|
+
<section class="card"><h3>${L('dashboard.maintenanceLog')}</h3>${maintenance.length ? `<ul class="feed compact">${[...maintenance].reverse().slice(0, 8).map((b) => feedRow({ time: b.date, kind: 'maintenance', title: `${b.kind}: ${b.findings}`, extra: b.work })).join('')}</ul>` : empty(L('dashboard.noMaintenance'), '/maintain weekly')}
|
|
274
283
|
${lastRetro ? `<p class="muted small">${L('dashboard.lastRetro', { value: e(lastRetro) })}</p>` : ''}</section>`;
|
|
275
284
|
|
|
276
285
|
const maxDay = Math.max(1, ...Object.values(dayCounts));
|
|
@@ -278,7 +287,7 @@ const activity = `
|
|
|
278
287
|
<section class="card"><h3>${L('dashboard.commitsLast14')} <span class="muted small">${L('dashboard.totalParen', { total: days.reduce((t, g) => t + dayCounts[g], 0) })}</span></h3>
|
|
279
288
|
<div class="spark" role="img" aria-label="${e(L('dashboard.sparkAria'))}">${days.map((g) => `<div class="sb" data-tip="${e(L('dashboard.commitTip', { date: toDate(g).toLocaleDateString(loc, { day: 'numeric', month: 'short', weekday: 'short' }), count: dayCounts[g] }))}"><span style="height:${dayCounts[g] ? Math.max(6, (dayCounts[g] / maxDay) * 100) : 0}%"></span></div>`).join('')}</div>
|
|
280
289
|
<div class="spark-x muted small"><span>${toDate(days[0]).toLocaleDateString(loc, { day: 'numeric', month: 'short' })}</span><span>${L('dashboard.today')}</span></div>
|
|
281
|
-
<details class="
|
|
290
|
+
<details class="table-view"><summary class="muted small">${L('dashboard.showTable')}</summary><div class="tw"><table><thead><tr><th>${L('dashboard.th.day')}</th><th>${L('dashboard.th.commit')}</th></tr></thead><tbody>${days.map((g) => `<tr><td class="num">${g}</td><td class="num">${dayCounts[g]}</td></tr>`).join('')}</tbody></table></div></details>
|
|
282
291
|
</section>
|
|
283
292
|
<section class="card"><h3>${L('dashboard.feed')}</h3>${feed.length ? `<ul class="feed">${feed.map(feedRow).join('')}</ul>` : empty(L('dashboard.noActivity'))}</section>`;
|
|
284
293
|
|
|
@@ -286,7 +295,7 @@ const product = `
|
|
|
286
295
|
<div class="grid2">
|
|
287
296
|
<section class="card"><h3>${L('dashboard.product')}</h3>
|
|
288
297
|
<h4>${L('dashboard.problem')}</h4>${firstSentence(findSection(prd, '1. Problem')) ? `<p>${md(firstSentence(findSection(prd, '1. Problem')))}</p>` : empty(L('dashboard.noPrd'), '/start')}
|
|
289
|
-
<h4>${L('dashboard.valueProp')}</h4>${firstSentence(findSection(prd, '3.
|
|
298
|
+
<h4>${L('dashboard.valueProp')}</h4>${firstSentence(findSection(prd, '3. Value')) ? `<p>${md(firstSentence(findSection(prd, '3. Value')))}</p>` : empty('-')}
|
|
290
299
|
${outOfScope.length ? `<h4>${L('dashboard.outOfScope')}</h4><ul>${outOfScope.map((k) => `<li>${md(k)}</li>`).join('')}</ul>` : ''}</section>
|
|
291
300
|
<section class="card"><h3>${L('dashboard.mvpFeatures')}</h3>${mvp.length ? `<div class="rows">${mvp.map((o) => `<div class="row"><span>${md(o.title)} ${o.priority ? `<span class="tag">${e(o.priority)}</span>` : ''}</span>${progressBar(o.done, o.total)}<span class="num muted">${o.done}/${o.total}</span></div>`).join('')}</div>` : empty(L('dashboard.noMvp'))}
|
|
292
301
|
${acceptance.total ? `<h4>${L('dashboard.criteriaResults')}</h4><div class="tw"><table><thead><tr><th>${L('dashboard.th.criterion')}</th><th>PRD</th><th>${L('dashboard.th.test')}</th></tr></thead><tbody>${acceptance.list.map((k) => `<tr><td><code>${e(k.id)}</code> ${md(k.text)}</td><td>${k.checked ? '✓' : ''}</td><td>${k.result === 'passed' ? badge('good', L('dashboard.passedBadge')) : k.result === 'failed' ? badge('critical', L('dashboard.failedBadge')) : k.test ? badge('info', L('dashboard.noResult')) : badge('warn', L('dashboard.noTest'))}</td></tr>`).join('')}</tbody></table></div>` : ''}</section>
|
|
@@ -298,7 +307,7 @@ const product = `
|
|
|
298
307
|
</div>
|
|
299
308
|
<div class="grid2">
|
|
300
309
|
<section class="card"><h3>${L('dashboard.unitEconomics')}</h3>${economics ? `<div class="kpis mini">${kpi(L('dashboard.kpiMargin'), `${economics.margin.toFixed(1)} ${e(economics.currency)}`, L('dashboard.perPayingUserMonth'))}${kpi(L('dashboard.kpiBreakEven'), economics.breakEven ?? '—', economics.breakEven ? L('dashboard.payingUsers') : L('dashboard.negativeMargin'))}${kpi(L('dashboard.atTarget', { target: economics.target }), `${economics.profit >= 0 ? '+' : ''}${Math.round(economics.profit)} ${e(economics.currency)}`, L('dashboard.monthlyProfitLoss'))}${economics.variableRatio != null ? kpi(L('dashboard.variableOverPrice'), `%${Math.round(economics.variableRatio)}`, economics.variableRatio > 30 ? badge('warn', L('dashboard.over30')) : '') : ''}</div>` : empty(L('dashboard.noInputs'), 'docs/product/unit-economics.md')}</section>
|
|
301
|
-
<section class="card"><h3>${L('dashboard.userFeedback')}</h3>${fb.records.length ? `<div class="kpis mini">${kpi(L('dashboard.kpiTotal'), fb.records.length)}${kpi(L('dashboard.kpiNew'), fb.newCount)}${kpi(L('dashboard.kpiPlanned'), fb.records.filter((k) =>
|
|
310
|
+
<section class="card"><h3>${L('dashboard.userFeedback')}</h3>${fb.records.length ? `<div class="kpis mini">${kpi(L('dashboard.kpiTotal'), fb.records.length)}${kpi(L('dashboard.kpiNew'), fb.newCount)}${kpi(L('dashboard.kpiPlanned'), fb.records.filter((k) => k.state === 'planned').length)}</div>${fb.themes.length ? `<h4>${L('dashboard.topThemes')}</h4><div class="rows">${fb.themes.slice(0, 5).map((t) => `<div class="row"><span>${md(t.theme)}</span>${progressBar(t.count, fb.themes[0].count)}<span class="num muted">${t.count}</span></div>`).join('')}</div>` : ''}` : empty(L('dashboard.noFeedback'), '/user-feedback')}</section>
|
|
302
311
|
</div>
|
|
303
312
|
${learnt ? `<section class="card"><h3>${L('dashboard.learning')}</h3><div class="kpis mini">${kpi(L('dashboard.kpiRight'), learnt.right)}${kpi(L('dashboard.kpiWrong'), learnt.wrong)}${kpi(L('dashboard.kpiSuccess'), `%${percent(learnt.right, learnt.right + learnt.wrong)}`)}</div>${learnt.missed.length ? `<h4>${L('dashboard.oftenMissed')}</h4><div class="rows">${learnt.missed.slice(0, 5).map((m) => `<div class="row"><span>${md(m.concept)}</span>${progressBar(m.count, learnt.missed[0].count)}<span class="num muted">${m.count}</span></div>`).join('')}</div>` : ''}</section>` : ''}
|
|
304
313
|
<div class="grid2">
|
|
@@ -354,9 +363,9 @@ const findingsSection = `
|
|
|
354
363
|
<p class="muted small">${L('dashboard.scoringNote')}</p></section>`;
|
|
355
364
|
|
|
356
365
|
const TABS = [
|
|
357
|
-
['
|
|
358
|
-
['
|
|
359
|
-
['
|
|
366
|
+
['overview', L('dashboard.tabOverview'), overview], ['roadmap', L('dashboard.tabRoadmap'), roadmapSection], ['decisions', L('dashboard.tabDecisions'), decisionTable],
|
|
367
|
+
['board', L('dashboard.board'), boardSection], ['findings', L('dashboard.tabFindings'), findingsSection], ['quality', L('dashboard.tabQuality'), quality], ['activity', L('dashboard.tabActivity'), activity],
|
|
368
|
+
['product', L('dashboard.tabProduct'), product], ['operations', L('dashboard.tabOperations'), operations], ['limit', L('dashboard.tabLimit'), limitSection],
|
|
360
369
|
];
|
|
361
370
|
|
|
362
371
|
const next = d.next[0] ?? (d.kitPending ? L('dashboard.startIdeaCode') : '-');
|
|
@@ -388,16 +397,16 @@ code{font:.86em var(--mono);background:var(--sunk);padding:.08em .38em;border-ra
|
|
|
388
397
|
.now-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px 16px}
|
|
389
398
|
.next{margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--accent-soft)}
|
|
390
399
|
.next .now-l{color:var(--accent)}
|
|
391
|
-
.
|
|
392
|
-
.
|
|
400
|
+
.copy-cmd{font:600 .72rem/1 var(--sans);padding:4px 8px;margin-left:6px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--fg);cursor:pointer;vertical-align:1px}
|
|
401
|
+
.copy-cmd:hover{border-color:var(--accent);color:var(--accent)}
|
|
393
402
|
.att{list-style:none;padding:0;gap:8px}.att li{display:grid;grid-template-columns:auto 1fr;gap:4px 10px;align-items:start;padding:8px 10px;border-radius:8px;background:var(--sunk)}
|
|
394
|
-
.att li.critical{background:var(--crit-bg)}.att li.warn{background:var(--warn-bg)}.att .
|
|
403
|
+
.att li.critical{background:var(--crit-bg)}.att li.warn{background:var(--warn-bg)}.att .action{grid-column:2;font-size:.8rem;color:var(--muted)}
|
|
395
404
|
.badge{display:inline-flex;gap:4px;align-items:center;font:600 .74rem/1 var(--sans);padding:4px 8px;border-radius:999px;white-space:nowrap}
|
|
396
405
|
.badge.good{background:var(--good-bg);color:var(--fg)}.badge.warn{background:var(--warn-bg);color:var(--fg)}.badge.critical{background:var(--crit-bg);color:var(--fg)}.badge.info{background:var(--info-bg);color:var(--fg)}
|
|
397
406
|
.ic{font-weight:700}.good .ic{color:var(--good)}.warn .ic{color:#b07800}.critical .ic{color:var(--crit)}.info .ic{color:var(--muted)}
|
|
398
407
|
@media (prefers-color-scheme:dark){.warn .ic{color:var(--warn)}}
|
|
399
408
|
.health{font-size:.85rem;padding:6px 12px}
|
|
400
|
-
.
|
|
409
|
+
.more{margin-top:8px}.more summary{cursor:pointer;font-size:.84rem;color:var(--accent);font-weight:600;padding:4px 0}.more .att{margin-top:8px}
|
|
401
410
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.kpis.mini{grid-template-columns:repeat(auto-fit,minmax(110px,1fr));margin-bottom:12px}
|
|
402
411
|
.kpi{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:12px 14px;display:grid;gap:4px;align-content:start;min-width:0}
|
|
403
412
|
.kpis.mini .kpi{background:var(--sunk);border:0}
|
|
@@ -418,8 +427,8 @@ code{font:.86em var(--mono);background:var(--sunk);padding:.08em .38em;border-ra
|
|
|
418
427
|
.step.now .step-n{background:var(--accent);color:var(--surface);border-color:var(--accent)}.step.done .step-n{background:var(--good);color:#fff;border-color:var(--good)}
|
|
419
428
|
.step-b{display:grid;gap:5px;min-width:0}.step-b b{font-size:.86rem}
|
|
420
429
|
.rows{display:grid;gap:10px}.row{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(60px,1fr) auto;gap:10px;align-items:center}
|
|
421
|
-
details.
|
|
422
|
-
details.
|
|
430
|
+
details.phase summary{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center;cursor:pointer;list-style:none}
|
|
431
|
+
details.phase summary::-webkit-details-marker{display:none}.phase-bar{display:flex;gap:8px;align-items:center;min-width:200px}.phase-bar .bar{flex:1}
|
|
423
432
|
.tasks{list-style:none;padding:0;margin-top:12px;gap:2px}.tasks li{display:grid;grid-template-columns:20px 1fr;gap:8px;padding:6px 8px;border-radius:6px}
|
|
424
433
|
.tasks li.ok{color:var(--muted)}.tasks li.active{background:var(--accent-soft)}
|
|
425
434
|
.chk{width:16px;height:16px;border:1.5px solid var(--line);border-radius:4px;display:grid;place-items:center;font-size:.7rem;margin-top:2px}
|
|
@@ -433,7 +442,7 @@ th{font:600 .7rem/1.2 var(--mono);text-transform:uppercase;letter-spacing:.06em;
|
|
|
433
442
|
.chip.on{background:var(--accent);border-color:var(--accent);color:var(--surface)}
|
|
434
443
|
.feed{list-style:none;padding:0;gap:0}.feed-i{display:grid;grid-template-columns:64px 1fr;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)}.feed-i:last-child{border-bottom:0}
|
|
435
444
|
.ft{font:600 .68rem/1 var(--mono);text-transform:uppercase;letter-spacing:.05em;padding:4px 6px;border-radius:5px;background:var(--sunk);text-align:center;align-self:start}
|
|
436
|
-
.t-
|
|
445
|
+
.t-decision .ft{background:var(--accent-soft);color:var(--accent)}.t-board .ft{background:var(--warn-bg)}.t-maintenance .ft{background:var(--good-bg)}
|
|
437
446
|
.members{list-style:none;padding:0;gap:8px}.members li{display:grid;gap:2px;padding:8px 10px;border-radius:8px;background:var(--sunk)}
|
|
438
447
|
.dl{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:0}.dl dt{color:var(--muted);font-size:.84rem}.dl dd{margin:0}
|
|
439
448
|
.links a{margin-right:8px}
|
|
@@ -441,7 +450,7 @@ th{font:600 .7rem/1.2 var(--mono);text-transform:uppercase;letter-spacing:.06em;
|
|
|
441
450
|
.sb{height:100%;display:flex;align-items:flex-end;cursor:default;position:relative}.sb span{display:block;width:100%;background:var(--accent);border-radius:4px 4px 0 0}
|
|
442
451
|
.sb:hover span{filter:brightness(1.15)}.spark-x{display:flex;justify-content:space-between;margin-top:4px}
|
|
443
452
|
#tip{position:fixed;pointer-events:none;background:var(--fg);color:var(--bg);font-size:.78rem;padding:5px 8px;border-radius:6px;opacity:0;transition:opacity .1s;z-index:10}
|
|
444
|
-
.
|
|
453
|
+
.table-view{margin-top:10px}.empty{color:var(--muted);font-size:.88rem}
|
|
445
454
|
.lim{display:grid;gap:8px;padding:12px;border-radius:10px;background:var(--sunk)}.lim-h{display:flex;justify-content:space-between;align-items:center}
|
|
446
455
|
footer{color:var(--muted);font-size:.78rem;padding-top:8px}
|
|
447
456
|
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
|
@@ -453,27 +462,27 @@ footer{color:var(--muted);font-size:.78rem;padding-top:8px}
|
|
|
453
462
|
${badge(health, health === 'good' ? L('dashboard.sevGood') : health === 'warn' ? L('dashboard.healthWarn') : L('dashboard.healthCritical')).replace('class="badge', 'class="badge health')}
|
|
454
463
|
${d.kit?.type ? `<span class="pill">${e(d.kit.type)}${d.kit.addons?.length ? ' + ' + e(d.kit.addons.join(', ')) : ''}</span><span class="pill">${e(L('dashboard.kitVersion', { version: d.kit.version }))}</span>${d.kit.tool?.some((t) => t !== 'claude') ? `<span class="pill">${e(d.kit.tool.join(' + '))}</span>` : ''}` : `<span class="pill">${e(L('dashboard.kitNotInstalled'))}</span>`}
|
|
455
464
|
${git?.branch ? `<span class="pill">⎇ ${e(git.branch)}</span>` : ''}
|
|
456
|
-
<label class="refresh"><input type="checkbox" id="
|
|
465
|
+
<label class="refresh"><input type="checkbox" id="auto-refresh" checked> ${e(L('dashboard.autoRefresh'))}</label>
|
|
457
466
|
</div>
|
|
458
467
|
</header>
|
|
459
468
|
|
|
460
469
|
<div class="hero">
|
|
461
|
-
<section class="card" aria-labelledby="
|
|
462
|
-
<div class="now-l" id="
|
|
463
|
-
<div class="now-v">${e(a['
|
|
470
|
+
<section class="card" aria-labelledby="now">
|
|
471
|
+
<div class="now-l" id="now">${L('dashboard.now')}</div>
|
|
472
|
+
<div class="now-v">${e(a['Active phase'] ?? '-')} · ${md(a['Active task'] ?? '-')}</div>
|
|
464
473
|
<div class="now-grid">
|
|
465
|
-
<div><div class="now-l">${L('dashboard.questionMode')}</div><div>${e(a['
|
|
466
|
-
<div><div class="now-l">${L('dashboard.profile')}</div><div>${e(a['
|
|
467
|
-
<div><div class="now-l">${L('dashboard.lastUpdate')}</div><div class="num">${e(a['
|
|
468
|
-
${
|
|
474
|
+
<div><div class="now-l">${L('dashboard.questionMode')}</div><div>${e(a['Question mode'] ?? '-')}</div></div>
|
|
475
|
+
<div><div class="now-l">${L('dashboard.profile')}</div><div>${e(a['Profile'] ?? '-')}</div></div>
|
|
476
|
+
<div><div class="now-l">${L('dashboard.lastUpdate')}</div><div class="num">${e(a['Last updated'] ?? '-')}</div></div>
|
|
477
|
+
${isValue(a['Active phase'], 'setup') ? `<div><div class="now-l">${L('dashboard.startStep')}</div><div class="num">${e(a['Setup step'] ?? '-')}</div></div>` : ''}
|
|
469
478
|
</div>
|
|
470
479
|
<div class="next"><div class="now-l">${L('dashboard.nextStep')}</div><div>${md(next)}${copyBtn(next)}</div>${cmdOf(next) ? `<div class="muted small">${e(L('dashboard.pasteHint'))}</div>` : ''}</div>
|
|
471
480
|
${d.state ? `<div class="now-l" style="margin-top:14px">${L('dashboard.status')}</div><p style="margin-top:4px">${md(d.state)}</p>` : ''}
|
|
472
481
|
${d.lastSession.length ? `<div class="now-l" style="margin-top:10px">${L('dashboard.lastSession')}</div><ul style="margin-top:4px">${d.lastSession.slice(0, 4).map((x) => `<li>${md(x)}</li>`).join('')}</ul>` : ''}
|
|
473
482
|
</section>
|
|
474
|
-
<section class="card" aria-labelledby="
|
|
475
|
-
<h3 id="
|
|
476
|
-
${alerts.length ? `<ul class="att">${alerts.slice(0, 6).map(alertRow).join('')}</ul>${alerts.length > 6 ? `<details class="
|
|
483
|
+
<section class="card" aria-labelledby="attention">
|
|
484
|
+
<h3 id="attention">${L('dashboard.attention')} <span class="muted small">(${alerts.length})</span></h3>
|
|
485
|
+
${alerts.length ? `<ul class="att">${alerts.slice(0, 6).map(alertRow).join('')}</ul>${alerts.length > 6 ? `<details class="more"><summary>${L('dashboard.moreItems', { count: alerts.length - 6 })}</summary><ul class="att">${alerts.slice(6).map(alertRow).join('')}</ul></details>` : ''}` : `<p>${badge('good', L('dashboard.allGood'))} ${L('dashboard.allGoodText')}</p>`}
|
|
477
486
|
</section>
|
|
478
487
|
</div>
|
|
479
488
|
|
|
@@ -521,14 +530,14 @@ ${TABS.map(([id, , content], i) => `<div class="panel" role="tabpanel" id="p-${i
|
|
|
521
530
|
c.addEventListener('click', function () {
|
|
522
531
|
document.querySelectorAll('.filters .chip').forEach(function (x) { x.classList.toggle('on', x === c); });
|
|
523
532
|
var k = c.dataset.k;
|
|
524
|
-
document.querySelectorAll('#
|
|
525
|
-
r.hidden = !(k === '
|
|
533
|
+
document.querySelectorAll('#decision-table tbody tr').forEach(function (r) {
|
|
534
|
+
r.hidden = !(k === 'all' || r.dataset.k === k || (k === 'automatic' && r.dataset.v === 'automatic') || (k === 'one-way' && r.dataset.door === 'one-way'));
|
|
526
535
|
});
|
|
527
536
|
});
|
|
528
537
|
});
|
|
529
538
|
|
|
530
539
|
// file:// may refuse the clipboard API: fall back to a selected textarea
|
|
531
|
-
document.querySelectorAll('.
|
|
540
|
+
document.querySelectorAll('.copy-cmd').forEach(function (b) {
|
|
532
541
|
b.addEventListener('click', function () {
|
|
533
542
|
var c = b.dataset.cmd;
|
|
534
543
|
function done(ok) { b.textContent = ok ? ${jsStr('dashboard.copied')} : ${jsStr('dashboard.copyFailed')}; setTimeout(function () { b.textContent = ${jsStr('dashboard.copy')}; }, 1500); }
|
|
@@ -543,7 +552,7 @@ ${TABS.map(([id, , content], i) => `<div class="panel" role="tabpanel" id="p-${i
|
|
|
543
552
|
el.addEventListener('mouseleave', function () { tip.style.opacity = 0; });
|
|
544
553
|
});
|
|
545
554
|
|
|
546
|
-
var auto = document.getElementById('
|
|
555
|
+
var auto = document.getElementById('auto-refresh'), timer;
|
|
547
556
|
try { auto.checked = localStorage.getItem('durum-oto') !== '0'; } catch (er) {}
|
|
548
557
|
function schedule() { clearTimeout(timer); if (auto.checked) timer = setTimeout(function () { location.reload(); }, 30000); }
|
|
549
558
|
auto.addEventListener('change', function () { try { localStorage.setItem('durum-oto', auto.checked ? '1' : '0'); } catch (er) {} schedule(); });
|
|
@@ -218,7 +218,7 @@ export function scopeIssue(command, dir, root) {
|
|
|
218
218
|
const vk = path.join(os.userInfo().homedir, '.vibe-kit');
|
|
219
219
|
const touchesApproval = (y) => VIBE_KIT.test(y) || !/^\.\.|^$/.test(path.relative(y, vk)) && !path.isAbsolute(path.relative(y, vk));
|
|
220
220
|
for (const h of targets) {
|
|
221
|
-
if (touchesApproval(h) || touchesApproval(realPath(h))) return { decision: 'ask', reason: /tercihler\.md$/i.test(h)
|
|
221
|
+
if (touchesApproval(h) || touchesApproval(realPath(h))) return { decision: 'ask', reason: /(preferences|tercihler)\.md$/i.test(h)
|
|
222
222
|
? t(root, 'guard.destructive.prefsFile', { path: h })
|
|
223
223
|
: t(root, 'guard.destructive.gateApproval', { path: h }) };
|
|
224
224
|
const rel = relPath(realPath(h), groot);
|
|
@@ -134,7 +134,7 @@ function hook() {
|
|
|
134
134
|
const paths = g.files.map(({ path: p }) => realPath(path.resolve(g.dir, p)));
|
|
135
135
|
// Gate command approval record (gate.mjs, in the user folder ~/.vibe-kit/): the AI must not write it by hand
|
|
136
136
|
const approval = paths.find((p) => /[\\/]\.vibe-kit([\\/]|$)/i.test(p));
|
|
137
|
-
if (approval) decide(g.tool, 'ask', /tercihler\.md$/i.test(approval)
|
|
137
|
+
if (approval) decide(g.tool, 'ask', /(preferences|tercihler)\.md$/i.test(approval)
|
|
138
138
|
? t(g.root, 'guard.freeze.prefsFile', { path: approval })
|
|
139
139
|
: t(g.root, 'guard.freeze.gateApproval', { path: approval }));
|
|
140
140
|
if (g.tool !== 'claude') {
|
package/.claude/hooks/gate.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Deterministic gate: lint, type check, tests and Semgrep run before the board meets; the output is input for the members.
|
|
3
3
|
// Why: the model should not search for what lint/type/test catch, and a red check should not be left to a model decision (F-03).
|
|
4
4
|
// node .claude/hooks/gate.mjs --run exit code: 0 green, 1 red
|
|
5
|
-
// Commands are read from AGENTS.md →
|
|
5
|
+
// Commands are read from AGENTS.md → Commands (lint, type check, test); an empty one ([ ]) counts as "unset".
|
|
6
6
|
// If Semgrep is installed it runs on changed code files (override with SEMGREP=<executable>); otherwise skipped, CI runs it.
|
|
7
7
|
// The result goes to .claude/state/gate.json with the same change signature as the commit gate; the commit gate stops
|
|
8
8
|
// the commit on a red or stale result. A green check's output is not written, the last 30 lines of a red one are.
|
|
@@ -18,12 +18,15 @@ import { spawnSync } from 'node:child_process';
|
|
|
18
18
|
import { changes } from './commit-gate.mjs';
|
|
19
19
|
import { isCodeFile } from './edit-rounds.mjs';
|
|
20
20
|
import { findRoot, isMain } from './shared.mjs';
|
|
21
|
+
import { normalize } from './labels.mjs';
|
|
21
22
|
import { specInvariants } from './dashboard-data.mjs';
|
|
22
23
|
|
|
23
|
-
// Keys of the AGENTS.md →
|
|
24
|
-
const CHECKS = ['lint', '
|
|
24
|
+
// Keys of the AGENTS.md → Commands block (Turkish keys of pre-3.48 projects are read through normalize)
|
|
25
|
+
const CHECKS = ['lint', 'type check', 'test'];
|
|
25
26
|
// The approval also covers the dev command and the preview address: preview.mjs runs the one and opens the other
|
|
26
|
-
const APPROVED = [...CHECKS, '
|
|
27
|
+
const APPROVED = [...CHECKS, 'dev', 'preview'];
|
|
28
|
+
// The digest keeps the pre-3.48 names, so an approval given before the English labels stays valid
|
|
29
|
+
const DIGEST_NAME = { 'type check': 'tip kontrol', dev: 'geliştirme', preview: 'önizleme' };
|
|
27
30
|
const TIMEOUT = 10 * 60 * 1000;
|
|
28
31
|
const BUFFER = 64 * 1024 * 1024;
|
|
29
32
|
// A red check shows its last 30 lines; a longer output is kept whole in a file, read only when the tail is not enough
|
|
@@ -37,9 +40,9 @@ function failed(root, name, r) {
|
|
|
37
40
|
return { output: lines.slice(-30).join('\n'), note: `last 30 lines; full output in ${rel}` };
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
// Only the "##
|
|
43
|
+
// Only the "## Commands" section is read: a "test:" line elsewhere must not count as a command
|
|
41
44
|
export function readCommands(md, names = CHECKS) {
|
|
42
|
-
const section = md.match(/^##
|
|
45
|
+
const section = normalize(md).match(/^## Commands[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m)?.[1] ?? '';
|
|
43
46
|
return names.map((name) => {
|
|
44
47
|
const value = section.match(new RegExp(`^${name}:[ \\t]*(.*?)[ \\t\\r]*$`, 'm'))?.[1];
|
|
45
48
|
return { name, command: value && !/^\[.*\]$/.test(value) ? value : null };
|
|
@@ -54,7 +57,7 @@ export function approvalPath(root) {
|
|
|
54
57
|
const key = crypto.createHash('sha256').update(realRoot.toLowerCase()).digest('hex').slice(0, 32);
|
|
55
58
|
return path.join(os.userInfo().homedir, '.vibe-kit', 'gate-approval', `${key}.json`);
|
|
56
59
|
}
|
|
57
|
-
const digest = (list) => crypto.createHash('sha256').update(JSON.stringify(list.map((k) => [k.name, k.command]))).digest('hex');
|
|
60
|
+
const digest = (list) => crypto.createHash('sha256').update(JSON.stringify(list.map((k) => [DIGEST_NAME[k.name] ?? k.name, k.command]))).digest('hex');
|
|
58
61
|
export const commandList = (list) => list.filter((k) => k.command).map((k) => `- ${k.name}: ${k.command}`).join('\n');
|
|
59
62
|
export const readAgents = (root) => { try { return fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'); } catch { return ''; } };
|
|
60
63
|
export const approvedCommands = (root) => readCommands(readAgents(root), APPROVED);
|
|
@@ -73,7 +76,7 @@ export function isApproved(root, list = approvedCommands(root)) {
|
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function check(root, { name, command }) {
|
|
76
|
-
if (!command) return { name, status: 'unset', note: 'AGENTS.md →
|
|
79
|
+
if (!command) return { name, status: 'unset', note: 'AGENTS.md → Commands' };
|
|
77
80
|
const start = Date.now();
|
|
78
81
|
// The command comes from AGENTS.md (a rule file, writes need approval); pipes and && need a shell
|
|
79
82
|
const r = spawnSync(command, { cwd: root, shell: true, encoding: 'utf8', timeout: TIMEOUT, maxBuffer: BUFFER });
|
|
@@ -154,7 +157,7 @@ export function absPathCheck(root, files) {
|
|
|
154
157
|
return { name, status: 'passed', ...(notes.length && { note: `${notes.length} lines have a path that looks like a user folder; fix it if it is machine-specific (does not block)`, output: list(notes) }) };
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export const approvalNeeded = (list) => `command approval needed\nAGENTS.md →
|
|
160
|
+
export const approvalNeeded = (list) => `command approval needed\nAGENTS.md → Commands is new or changed; these commands do not run unapproved:\n${commandList(list)}\n` +
|
|
158
161
|
'Show the commands to the user and ask. If they approve: node .claude/hooks/gate.mjs --approve, then run the command again.';
|
|
159
162
|
|
|
160
163
|
// Spec drift (F-23): a "must not change" behavior whose test is missing or red stops the gate; no result is only a note
|
|
@@ -166,7 +169,7 @@ function specCheck(root, since) {
|
|
|
166
169
|
const unknown = list.filter((k) => k.test && !k.result).length;
|
|
167
170
|
const notes = [`${list.length} behaviors that must not change`,
|
|
168
171
|
unknown && `${unknown} tests have no result in this run (the test command writes no JUnit report)`,
|
|
169
|
-
unreadable.length && `${unreadable.length} lines could not be read, write them as "-
|
|
172
|
+
unreadable.length && `${unreadable.length} lines could not be read, write them as "- AC-01.1: ...": ${unreadable.map((u) => `${u.spec}: ${u.line}`).join(' · ')}`];
|
|
170
173
|
return { name: 'Spec', status: bad.length ? 'failed' : 'passed', note: notes.filter(Boolean).join('; '),
|
|
171
174
|
output: bad.length ? bad.map((k) => `${k.id} (${k.spec}): ${k.test ? 'test red' : 'no test'}`).join('\n') : undefined };
|
|
172
175
|
}
|
|
@@ -193,7 +196,7 @@ export function runGate(root) {
|
|
|
193
196
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
194
197
|
fs.writeFileSync(file, JSON.stringify({ signature, date: new Date().toISOString(), passed, empty, checks }, null, 2));
|
|
195
198
|
const result = checks.some((k) => k.status === 'failed') ? 'Result: RED. The commit gate stays closed: fix it and run the gate again.'
|
|
196
|
-
: empty ? 'Result: EMPTY, no check ran. Fill in AGENTS.md →
|
|
199
|
+
: empty ? 'Result: EMPTY, no check ran. Fill in AGENTS.md → Commands; the commit gate stays closed (if the user wants, commit-gate.mjs --skip).'
|
|
197
200
|
: 'Result: GREEN.';
|
|
198
201
|
return { passed, text: [`## Gate · ${files.length} files changed`, ...checks.map(line), result].join('\n') };
|
|
199
202
|
}
|