feat(reader): STATUS.md parser + TS project scaffold [skip-tdd: visual for configs]
- vitest+TS scaffold (package.json/tsconfig/vitest.config = config artifacts) - src/parser.ts: parses STATUS.md blocks → StatusBlock[] - tests/parser.test.ts: 6 cases (single, multi, blocker, null-blocker, empty, unknown emoji) - fix emoji contract: align with using-tasks reality (⚪ open / 🔴 in_progress / 🟡 paused / 🔵 blocked / 🟢 done); reader-task acceptance + design doc updated Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
project: string, // repo name (e.g. "board-viewer", "books")
|
project: string, // repo name (e.g. "board-viewer", "books")
|
||||||
project_owner: string, // gitea owner (e.g. "OpeItcLoc03")
|
project_owner: string, // gitea owner (e.g. "OpeItcLoc03")
|
||||||
status: "open" | "in_progress" | "paused" | "blocked" | "done",
|
status: "open" | "in_progress" | "paused" | "blocked" | "done",
|
||||||
status_emoji: "⚪" | "🟡" | "🟣" | "🔴" | "🟢",
|
status_emoji: "⚪" | "🔴" | "🟡" | "🔵" | "🟢",
|
||||||
title: string, // from STATUS.md block H2 after slug
|
title: string, // from STATUS.md block H2 after slug
|
||||||
where_stopped: string | null,
|
where_stopped: string | null,
|
||||||
next_action: string | null,
|
next_action: string | null,
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
## Decisions log
|
## Decisions log
|
||||||
|
|
||||||
- 2026-05-22: task создан промоушеном; контракт фиксирован — это публичный API для html-render.
|
- 2026-05-22: task создан промоушеном; контракт фиксирован — это публичный API для html-render.
|
||||||
|
- 2026-05-22: emoji-словарь выравнен под реальные `.tasks/STATUS.md` (using-tasks skill): ⚪ open / 🔴 in_progress / 🟡 paused / 🔵 blocked / 🟢 done. Раньше дизайн-док и acceptance путали 🔴/🔵 — исправлено.
|
||||||
|
- 2026-05-22: язык — TypeScript / Node (vitest, ESM, Node 22). Repo discovery — whitelist `board_viewer_repos` в auth.toml. Без кэша Gitea API на MVP.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ sources:
|
|||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Сейчас визуала прогресса по таскам нет — есть только текстовые формы: `.tasks/STATUS.md` per-project с emoji-легендой (⚪ open / 🟡 in-progress / 🟣 paused / 🔴 blocked / 🟢 done) и `mcp__projects-meta__tasks_aggregate` поверх Gitea-репо `OpeItcLoc03/agenda`. Цель — визуальная kanban-доска для глаз.
|
Сейчас визуала прогресса по таскам нет — есть только текстовые формы: `.tasks/STATUS.md` per-project с emoji-легендой (⚪ open / 🟡 paused / 🔴 in-progress / 🟢 done / 🔵 blocked) и `mcp__projects-meta__tasks_aggregate` поверх Gitea-репо `OpeItcLoc03/agenda`. Цель — визуальная kanban-доска для глаз.
|
||||||
|
|
||||||
## Что увидено у hermes-agent
|
## Что увидено у hermes-agent
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ sources:
|
|||||||
|
|
||||||
## Решение: read-only HTML kanban-viewer над Gitea API, хостинг на VDS
|
## Решение: read-only HTML kanban-viewer над Gitea API, хостинг на VDS
|
||||||
|
|
||||||
**Что:** маленький HTTP-сервис на VDS (рядом с Gitea, traefik уже там), который читает Gitea API напрямую (`OpeItcLoc03/agenda` + per-project репо) и рендерит 5-колоночную доску по emoji-словарю: ⚪ open / 🟡 in-progress / 🟣 paused / 🔴 blocked / 🟢 done.
|
**Что:** маленький HTTP-сервис на VDS (рядом с Gitea, traefik уже там), который читает Gitea API напрямую (`OpeItcLoc03/agenda` + per-project репо) и рендерит 5-колоночную доску по emoji-словарю: ⚪ open / 🟡 paused / 🔴 in-progress / 🟢 done / 🔵 blocked.
|
||||||
|
|
||||||
**Где:** домен `board.kzntsv.site` за traefik, рядом с `git.kzntsv.site`.
|
**Где:** домен `board.kzntsv.site` за traefik, рядом с `git.kzntsv.site`.
|
||||||
|
|
||||||
|
|||||||
1464
package-lock.json
generated
Normal file
1464
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "board-viewer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Read-only HTML kanban viewer over Gitea API",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/parser.ts
Normal file
77
src/parser.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export type Status = 'open' | 'in_progress' | 'paused' | 'blocked' | 'done';
|
||||||
|
export type StatusEmoji = '⚪' | '🔴' | '🟡' | '🔵' | '🟢';
|
||||||
|
|
||||||
|
export interface StatusBlock {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
status: Status;
|
||||||
|
status_emoji: StatusEmoji;
|
||||||
|
where_stopped: string | null;
|
||||||
|
next_action: string | null;
|
||||||
|
blocker: string | null;
|
||||||
|
branch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_TO_STATUS: Record<StatusEmoji, Status> = {
|
||||||
|
'⚪': 'open',
|
||||||
|
'🔴': 'in_progress',
|
||||||
|
'🟡': 'paused',
|
||||||
|
'🔵': 'blocked',
|
||||||
|
'🟢': 'done',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_EMOJIS = Object.keys(EMOJI_TO_STATUS) as StatusEmoji[];
|
||||||
|
|
||||||
|
const H2_RE = /^##\s+(\S+)\s+\[([^\]]+)\]\s+—\s+(.+?)\s*$/u;
|
||||||
|
const FIELD_RE = /^\*\*([^:*]+):\*\*\s*(.*)$/;
|
||||||
|
|
||||||
|
export function parseStatus(text: string): StatusBlock[] {
|
||||||
|
const blocks: StatusBlock[] = [];
|
||||||
|
|
||||||
|
for (const rawBlock of splitBlocks(text)) {
|
||||||
|
const block = parseOneBlock(rawBlock);
|
||||||
|
if (block !== null) blocks.push(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitBlocks(text: string): string[] {
|
||||||
|
return text.split(/^---\s*$/m).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOneBlock(raw: string): StatusBlock | null {
|
||||||
|
const lines = raw.split(/\r?\n/);
|
||||||
|
let header: { emoji: StatusEmoji; slug: string; title: string } | null = null;
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (header === null) {
|
||||||
|
const m = H2_RE.exec(line);
|
||||||
|
if (m === null) continue;
|
||||||
|
const [, emoji, slug, title] = m;
|
||||||
|
if (emoji === undefined || slug === undefined || title === undefined) continue;
|
||||||
|
if (!KNOWN_EMOJIS.includes(emoji as StatusEmoji)) continue;
|
||||||
|
header = { emoji: emoji as StatusEmoji, slug, title };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fm = FIELD_RE.exec(line);
|
||||||
|
if (fm !== null) {
|
||||||
|
const [, key, value] = fm;
|
||||||
|
if (key !== undefined && value !== undefined) fields[key.trim()] = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header === null) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: header.slug,
|
||||||
|
title: header.title,
|
||||||
|
status: EMOJI_TO_STATUS[header.emoji],
|
||||||
|
status_emoji: header.emoji,
|
||||||
|
where_stopped: fields['Where I stopped'] ?? null,
|
||||||
|
next_action: fields['Next action'] ?? null,
|
||||||
|
blocker: fields['Blocker'] ?? null,
|
||||||
|
branch: fields['Branch'] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
27
tests/fixtures/status-multi.md
vendored
Normal file
27
tests/fixtures/status-multi.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Task Board
|
||||||
|
_Updated: 2026-05-22_
|
||||||
|
|
||||||
|
## 🔴 [task-a] — active task
|
||||||
|
**Status:** active
|
||||||
|
**Where I stopped:** working on A.
|
||||||
|
**Next action:** continue A.
|
||||||
|
**Branch:** master
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔵 [task-b] — blocked task
|
||||||
|
**Status:** blocked
|
||||||
|
**Where I stopped:** waiting.
|
||||||
|
**Next action:** unblock.
|
||||||
|
**Blocker:** task-a
|
||||||
|
**Branch:** master
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚪ [task-c] — ready task
|
||||||
|
**Status:** ready
|
||||||
|
**Where I stopped:** not started.
|
||||||
|
**Next action:** start C.
|
||||||
|
**Branch:** master
|
||||||
|
|
||||||
|
---
|
||||||
10
tests/fixtures/status-single-block.md
vendored
Normal file
10
tests/fixtures/status-single-block.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Task Board
|
||||||
|
_Updated: 2026-05-22_
|
||||||
|
|
||||||
|
## 🔴 [board-viewer-pointers] — pre-impl: fill Domain conventions with design-context pointers
|
||||||
|
**Status:** active
|
||||||
|
**Where I stopped:** только что промочен дизайн.
|
||||||
|
**Next action:** вставить блок в `.wiki/CLAUDE.md`.
|
||||||
|
**Branch:** master
|
||||||
|
|
||||||
|
---
|
||||||
56
tests/parser.test.ts
Normal file
56
tests/parser.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { parseStatus } from '../src/parser.ts';
|
||||||
|
|
||||||
|
const fixture = (name: string) =>
|
||||||
|
readFileSync(join(__dirname, 'fixtures', name), 'utf8');
|
||||||
|
|
||||||
|
describe('parseStatus', () => {
|
||||||
|
test('parses multiple blocks preserving order and statuses', () => {
|
||||||
|
const blocks = parseStatus(fixture('status-multi.md'));
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(3);
|
||||||
|
expect(blocks.map((b) => b.slug)).toEqual(['task-a', 'task-b', 'task-c']);
|
||||||
|
expect(blocks.map((b) => b.status)).toEqual(['in_progress', 'blocked', 'open']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses Blocker field when present', () => {
|
||||||
|
const blocks = parseStatus(fixture('status-multi.md'));
|
||||||
|
const blocked = blocks.find((b) => b.slug === 'task-b');
|
||||||
|
|
||||||
|
expect(blocked?.blocker).toBe('task-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Blocker field is null when absent', () => {
|
||||||
|
const blocks = parseStatus(fixture('status-multi.md'));
|
||||||
|
const ready = blocks.find((b) => b.slug === 'task-c');
|
||||||
|
|
||||||
|
expect(ready?.blocker).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array for STATUS.md without task blocks', () => {
|
||||||
|
expect(parseStatus('# Task Board\n_Updated: 2026-05-22_\n\nNo tasks yet.\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores blocks whose H2 has no known status emoji', () => {
|
||||||
|
const text = '## 🚀 [some-task] — title\n**Status:** custom\n**Branch:** master\n---\n';
|
||||||
|
expect(parseStatus(text)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses a single active block with all fields', () => {
|
||||||
|
const blocks = parseStatus(fixture('status-single-block.md'));
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0]).toEqual({
|
||||||
|
slug: 'board-viewer-pointers',
|
||||||
|
title: 'pre-impl: fill Domain conventions with design-context pointers',
|
||||||
|
status: 'in_progress',
|
||||||
|
status_emoji: '🔴',
|
||||||
|
where_stopped: 'только что промочен дизайн.',
|
||||||
|
next_action: 'вставить блок в `.wiki/CLAUDE.md`.',
|
||||||
|
blocker: null,
|
||||||
|
branch: 'master',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user