@tomkapa/tayto 0.1.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/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # tayto
2
+
3
+ Task management for solo developers and AI agents. Two interfaces, one database.
4
+
5
+ - **CLI** returns structured JSON — built for AI agents and scripting
6
+ - **TUI** renders rich markdown in the terminal — built for humans
7
+
8
+ SQLite-backed. No server. No login. Just projects and tasks.
9
+
10
+ ---
11
+
12
+ ## Quick Start
13
+
14
+ ```bash
15
+ # Install
16
+ npm install && npm run build
17
+
18
+ # Create a project
19
+ tayto project create -n "my-app" --default
20
+
21
+ # Create tasks
22
+ tayto task create -n "Fix auth bug" -t bug --priority 1
23
+ tayto task create -n "Add dashboard" -t story --priority 2
24
+
25
+ # Launch the terminal UI
26
+ tayto
27
+ ```
28
+
29
+ ## Installation
30
+
31
+ **Requirements:** Node.js >= 18
32
+
33
+ ```bash
34
+ git clone <repo-url> && cd tayto
35
+ npm install
36
+ npm run build
37
+ npm link # makes `tayto` available globally
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ### Terminal UI
43
+
44
+ Run `tayto` with no arguments to launch the interactive TUI.
45
+
46
+ ```bash
47
+ tayto # launch TUI (default)
48
+ tayto tui # explicit launch
49
+ tayto tui -p "my-app" # start with a specific project
50
+ ```
51
+
52
+ #### Keyboard Shortcuts
53
+
54
+ | Key | Action |
55
+ |---|---|
56
+ | `j` / `k` / `arrows` | Navigate up/down |
57
+ | `Enter` | Open task detail |
58
+ | `c` | Create task |
59
+ | `e` | Edit task |
60
+ | `d` | Delete task (with confirmation) |
61
+ | `s` | Cycle status forward |
62
+ | `/` | Search tasks |
63
+ | `f` | Cycle status filter |
64
+ | `t` | Cycle type filter |
65
+ | `1`-`5` | Toggle priority filter |
66
+ | `0` | Clear all filters |
67
+ | `p` | Switch project |
68
+ | `Esc` / `b` | Go back |
69
+ | `?` | Show help |
70
+ | `q` | Quit |
71
+
72
+ The task detail view renders **markdown**, **code blocks**, and **technical notes** directly in the terminal.
73
+
74
+ ### CLI Commands
75
+
76
+ All commands output JSON to stdout. Errors go to stderr with exit code 1.
77
+
78
+ ```jsonc
79
+ // success
80
+ { "ok": true, "data": { ... } }
81
+
82
+ // error
83
+ { "ok": false, "error": { "code": "NOT_FOUND", "message": "..." } }
84
+ ```
85
+
86
+ #### Project
87
+
88
+ ```bash
89
+ tayto project create -n "my-app" -d "Description" --default
90
+ tayto project list
91
+ tayto project update <id> -n "new-name" --default
92
+ tayto project delete <id>
93
+ ```
94
+
95
+ #### Task
96
+
97
+ ```bash
98
+ # Create
99
+ tayto task create \
100
+ -n "Fix login bug" \
101
+ -t bug \
102
+ -s todo \
103
+ --priority 1 \
104
+ -p "my-app" \
105
+ -d "Login fails on mobile" \
106
+ --technical-notes "Check JWT expiry" \
107
+ --additional-requirements "Must work on iOS Safari"
108
+
109
+ # Read
110
+ tayto task list
111
+ tayto task list --status in-progress --type bug --search "login"
112
+ tayto task list --priority 1 --parent <parent-id>
113
+ tayto task show <id>
114
+
115
+ # Update
116
+ tayto task update <id> -s in-progress
117
+ tayto task update <id> --append-notes "Root cause: token not refreshed"
118
+ tayto task update <id> --append-requirements "Also fix on Android"
119
+
120
+ # Delete
121
+ tayto task delete <id>
122
+
123
+ # Breakdown (create subtasks from JSON)
124
+ tayto task breakdown <parent-id> -f subtasks.json
125
+ ```
126
+
127
+ **subtasks.json** example:
128
+
129
+ ```json
130
+ [
131
+ { "name": "Implement API endpoint", "type": "story", "priority": 2 },
132
+ { "name": "Write integration tests", "type": "story", "priority": 3 }
133
+ ]
134
+ ```
135
+
136
+ ## Data Model
137
+
138
+ ### Task Types
139
+
140
+ | Type | Description |
141
+ |---|---|
142
+ | `story` | Feature or user story |
143
+ | `tech-debt` | Refactoring or cleanup |
144
+ | `bug` | Defect or issue |
145
+
146
+ ### Task Statuses
147
+
148
+ `backlog` &rarr; `todo` &rarr; `in-progress` &rarr; `review` &rarr; `done`
149
+
150
+ `cancelled` is also available for abandoned tasks.
151
+
152
+ ### Priority
153
+
154
+ | Level | Label |
155
+ |---|---|
156
+ | 1 | Critical |
157
+ | 2 | High |
158
+ | 3 | Medium (default) |
159
+ | 4 | Low |
160
+ | 5 | Lowest |
161
+
162
+ ### Task Breakdown
163
+
164
+ Tasks support a `parent_id` field for hierarchical decomposition. Use `task breakdown` to batch-create subtasks under a parent, or pass `--parent <id>` on `task create`.
165
+
166
+ ## Configuration
167
+
168
+ Configure via environment variables.
169
+
170
+ | Variable | Default | Description |
171
+ |---|---|---|
172
+ | `TASK_DB_PATH` | `~/.task/data.db` | Path to SQLite database |
173
+ | `TASK_DATA_DIR` | `~/.task` | Data directory |
174
+ | `TASK_LOG_LEVEL` | `info` | Log level (`debug`, `info`, `warn`, `error`) |
175
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | &mdash; | OpenTelemetry collector endpoint |
176
+
177
+ The database and data directory are created automatically on first run.
178
+
179
+ ## Architecture
180
+
181
+ ```
182
+ CLI Commands ──┐
183
+ ├──> Service Layer ──> Repository Layer ──> SQLite
184
+ Terminal UI ───┘
185
+ ```
186
+
187
+ - **Service layer** handles validation (Zod), business logic, and project resolution
188
+ - **Repository layer** handles SQL queries with parameterized statements
189
+ - **Result\<T\>** return type across all layers &mdash; no thrown exceptions for business logic
190
+ - **OpenTelemetry** spans on every service and repository operation
191
+ - **ULID** identifiers &mdash; sortable, no database round-trip
192
+
193
+ ```
194
+ src/
195
+ cli/ # Commander.js commands, JSON output
196
+ tui/ # Ink (React) terminal UI components
197
+ service/ # Business logic
198
+ repository/ # Data access
199
+ db/ # SQLite connection, migrations
200
+ types/ # Zod schemas, enums, Result type
201
+ errors/ # Typed error hierarchy
202
+ logging/ # OpenTelemetry tracer
203
+ config/ # Environment-based configuration
204
+ ```
205
+
206
+ ## Development
207
+
208
+ ```bash
209
+ npm run dev # build in watch mode
210
+ npm run check # prettier + eslint
211
+ npm run test # run tests
212
+ npm run build # production build
213
+ ```
214
+
215
+ ## License
216
+
217
+ MIT
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/logging/logger.ts
4
+ import { appendFileSync, readdirSync, unlinkSync } from "fs";
5
+ import { join } from "path";
6
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
7
+ var tracer = trace.getTracer("task");
8
+ var LOG_RETENTION_DAYS = 7;
9
+ function formatTimestamp() {
10
+ return (/* @__PURE__ */ new Date()).toISOString();
11
+ }
12
+ function formatAttrs(attrs) {
13
+ if (!attrs || Object.keys(attrs).length === 0) return "";
14
+ return " " + JSON.stringify(attrs);
15
+ }
16
+ var Logger = class {
17
+ logFilePath = null;
18
+ init(logDir) {
19
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
20
+ this.logFilePath = join(logDir, `task-${date}.log`);
21
+ this.pruneOldLogs(logDir);
22
+ }
23
+ info(message, attrs) {
24
+ this.write("INFO", message, attrs);
25
+ const span = trace.getActiveSpan();
26
+ if (span) {
27
+ span.addEvent(message, attrs);
28
+ }
29
+ }
30
+ warn(message, attrs) {
31
+ this.write("WARN", message, attrs);
32
+ const span = trace.getActiveSpan();
33
+ if (span) {
34
+ span.addEvent(`WARN: ${message}`, attrs);
35
+ }
36
+ }
37
+ error(message, error, attrs) {
38
+ const errorDetail = error instanceof Error ? ` | ${error.stack ?? error.message}` : "";
39
+ this.write("ERROR", `${message}${errorDetail}`, attrs);
40
+ const span = trace.getActiveSpan();
41
+ if (span) {
42
+ span.addEvent(`ERROR: ${message}`, attrs);
43
+ if (error instanceof Error) {
44
+ span.recordException(error);
45
+ }
46
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
47
+ }
48
+ }
49
+ startSpan(name, fn) {
50
+ return tracer.startActiveSpan(name, (span) => {
51
+ try {
52
+ const result = fn(span);
53
+ span.end();
54
+ return result;
55
+ } catch (e) {
56
+ if (e instanceof Error) {
57
+ span.recordException(e);
58
+ }
59
+ span.setStatus({ code: SpanStatusCode.ERROR });
60
+ span.end();
61
+ throw e;
62
+ }
63
+ });
64
+ }
65
+ write(level, message, attrs) {
66
+ if (!this.logFilePath) return;
67
+ const line = `${formatTimestamp()} [${level}] ${message}${formatAttrs(attrs)}
68
+ `;
69
+ try {
70
+ appendFileSync(this.logFilePath, line);
71
+ } catch {
72
+ }
73
+ }
74
+ pruneOldLogs(logDir) {
75
+ try {
76
+ const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
77
+ const files = readdirSync(logDir).filter((f) => f.startsWith("task-") && f.endsWith(".log"));
78
+ for (const file of files) {
79
+ const dateStr = file.slice("task-".length, -".log".length);
80
+ const fileDate = new Date(dateStr).getTime();
81
+ if (!isNaN(fileDate) && fileDate < cutoff) {
82
+ unlinkSync(join(logDir, file));
83
+ }
84
+ }
85
+ } catch {
86
+ }
87
+ }
88
+ };
89
+ var logger = new Logger();
90
+
91
+ // src/types/enums.ts
92
+ var TaskStatus = {
93
+ Backlog: "backlog",
94
+ Todo: "todo",
95
+ InProgress: "in-progress",
96
+ Review: "review",
97
+ Done: "done",
98
+ Cancelled: "cancelled"
99
+ };
100
+ var TaskType = {
101
+ Story: "story",
102
+ TechDebt: "tech-debt",
103
+ Bug: "bug"
104
+ };
105
+ var DependencyType = {
106
+ Blocks: "blocks",
107
+ RelatesTo: "relates-to",
108
+ Duplicates: "duplicates"
109
+ };
110
+ var UIDependencyType = {
111
+ ...DependencyType,
112
+ BlockedBy: "blocked-by"
113
+ };
114
+ var RANK_GAP = 1e3;
115
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
116
+ TaskStatus.Done,
117
+ TaskStatus.Cancelled
118
+ ]);
119
+ function isTerminalStatus(status) {
120
+ return TERMINAL_STATUSES.has(status);
121
+ }
122
+
123
+ export {
124
+ logger,
125
+ TaskStatus,
126
+ TaskType,
127
+ DependencyType,
128
+ UIDependencyType,
129
+ RANK_GAP,
130
+ TERMINAL_STATUSES,
131
+ isTerminalStatus
132
+ };
133
+ //# sourceMappingURL=chunk-6NQOFUIQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logging/logger.ts","../src/types/enums.ts"],"sourcesContent":["import { appendFileSync, readdirSync, unlinkSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { trace, type Span, SpanStatusCode } from '@opentelemetry/api';\n\nconst tracer = trace.getTracer('task');\nconst LOG_RETENTION_DAYS = 7;\n\nexport interface LogAttributes {\n [key: string]: string | number | boolean;\n}\n\ntype LogLevel = 'INFO' | 'WARN' | 'ERROR';\n\nfunction formatTimestamp(): string {\n return new Date().toISOString();\n}\n\nfunction formatAttrs(attrs?: LogAttributes): string {\n if (!attrs || Object.keys(attrs).length === 0) return '';\n return ' ' + JSON.stringify(attrs);\n}\n\nclass Logger {\n private logFilePath: string | null = null;\n\n init(logDir: string): void {\n const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD\n this.logFilePath = join(logDir, `task-${date}.log`);\n this.pruneOldLogs(logDir);\n }\n\n info(message: string, attrs?: LogAttributes): void {\n this.write('INFO', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(message, attrs);\n }\n }\n\n warn(message: string, attrs?: LogAttributes): void {\n this.write('WARN', message, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`WARN: ${message}`, attrs);\n }\n }\n\n error(message: string, error?: unknown, attrs?: LogAttributes): void {\n const errorDetail = error instanceof Error ? ` | ${error.stack ?? error.message}` : '';\n this.write('ERROR', `${message}${errorDetail}`, attrs);\n const span = trace.getActiveSpan();\n if (span) {\n span.addEvent(`ERROR: ${message}`, attrs);\n if (error instanceof Error) {\n span.recordException(error);\n }\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n }\n }\n\n startSpan<T>(name: string, fn: (span: Span) => T): T {\n return tracer.startActiveSpan(name, (span) => {\n try {\n const result = fn(span);\n span.end();\n return result;\n } catch (e) {\n if (e instanceof Error) {\n span.recordException(e);\n }\n span.setStatus({ code: SpanStatusCode.ERROR });\n span.end();\n throw e;\n }\n });\n }\n\n private write(level: LogLevel, message: string, attrs?: LogAttributes): void {\n if (!this.logFilePath) return;\n const line = `${formatTimestamp()} [${level}] ${message}${formatAttrs(attrs)}\\n`;\n try {\n appendFileSync(this.logFilePath, line);\n } catch {\n // Swallowing here is intentional: logging must never crash the app.\n // If the log file is unwritable, the OTel span still captures the event.\n }\n }\n\n private pruneOldLogs(logDir: string): void {\n try {\n const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;\n const files = readdirSync(logDir).filter((f) => f.startsWith('task-') && f.endsWith('.log'));\n for (const file of files) {\n const dateStr = file.slice('task-'.length, -'.log'.length);\n const fileDate = new Date(dateStr).getTime();\n if (!isNaN(fileDate) && fileDate < cutoff) {\n unlinkSync(join(logDir, file));\n }\n }\n } catch {\n // Best-effort cleanup — don't crash if pruning fails\n }\n }\n}\n\nexport const logger = new Logger();\n","export const TaskStatus = {\n Backlog: 'backlog',\n Todo: 'todo',\n InProgress: 'in-progress',\n Review: 'review',\n Done: 'done',\n Cancelled: 'cancelled',\n} as const;\nexport type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];\n\nexport const TaskType = {\n Story: 'story',\n TechDebt: 'tech-debt',\n Bug: 'bug',\n} as const;\nexport type TaskType = (typeof TaskType)[keyof typeof TaskType];\n\n/** Types stored in the database. */\nexport const DependencyType = {\n Blocks: 'blocks',\n RelatesTo: 'relates-to',\n Duplicates: 'duplicates',\n} as const;\nexport type DependencyType = (typeof DependencyType)[keyof typeof DependencyType];\n\n/**\n * UI-level dependency types — includes BlockedBy which is a reverse-Blocks\n * relationship resolved before persisting to the database.\n */\nexport const UIDependencyType = {\n ...DependencyType,\n BlockedBy: 'blocked-by',\n} as const;\nexport type UIDependencyType = (typeof UIDependencyType)[keyof typeof UIDependencyType];\n\n/** Gap between consecutive rank values, used for insertion between neighbors. */\nexport const RANK_GAP = 1000.0;\n\n/** Statuses that represent terminal/completed task states. */\nexport const TERMINAL_STATUSES: ReadonlySet<string> = new Set([\n TaskStatus.Done,\n TaskStatus.Cancelled,\n]);\n\nexport function isTerminalStatus(status: string): boolean {\n return TERMINAL_STATUSES.has(status);\n}\n"],"mappings":"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","names":[]}