create-task-ops 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # create-task-ops
2
+
3
+ `Next.js` 고정 기반의 task-ops 스캐폴드 생성기다.
4
+
5
+ ## 목표
6
+
7
+ - 새 리포를 빠르게 시작
8
+ - 기존 리포에 task-ops 구조 주입
9
+ - `tasks/*.md` 와 Task API 계약을 기본으로 포함
10
+
11
+ ## 모드
12
+
13
+ - `full`
14
+ Next.js 앱, 기본 대시보드 페이지, Task API, tasks 문서 포함
15
+ - `api`
16
+ Next.js 앱, Task API 중심, 최소 페이지 포함
17
+ - `docs`
18
+ `tasks/`, `CLAUDE.md`, `AGENT.md`, 계약 문서만 포함
19
+
20
+ ## 사용 예시
21
+
22
+ ```bash
23
+ node packages/create-task-ops/bin/create-task-ops.js my-project
24
+ node packages/create-task-ops/bin/create-task-ops.js my-project --mode api
25
+ node packages/create-task-ops/bin/create-task-ops.js --init --mode docs
26
+ ```
27
+
28
+ publish 후에는 아래처럼 쓸 수 있게 설계했다.
29
+
30
+ ```bash
31
+ npx create-task-ops my-project
32
+ npx create-task-ops my-project --mode api
33
+ npx create-task-ops --init --mode docs
34
+ ```
35
+
36
+ ## 로컬 테스트
37
+
38
+ ```bash
39
+ npm run create-task-ops -- my-project
40
+ npm run create-task-ops -- --init --mode docs
41
+ cd packages/create-task-ops
42
+ npm run smoke:full
43
+ ```
44
+
45
+ ## 퍼블리시 전 체크
46
+
47
+ ```bash
48
+ cd packages/create-task-ops
49
+ npm run smoke:docs
50
+ npm run pack:dry-run
51
+ ```
52
+
53
+ ## 배포 전 수동 확인
54
+
55
+ - `npm view create-task-ops name`
56
+ - `npm whoami`
57
+ - `npm version patch | minor | major`
58
+ - `npm publish --access public`
59
+
60
+ 만약 로컬 npm 캐시 권한 문제가 있으면 `pack:dry-run` 처럼 임시 캐시를 쓰는 편이 안전하다.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ const args = process.argv.slice(2);
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../..");
9
+ const templatesRoot = path.join(repoRoot, "packages/create-task-ops/templates");
10
+
11
+ function parseArgs(argv) {
12
+ const options = {
13
+ name: null,
14
+ mode: "full",
15
+ init: false,
16
+ force: false,
17
+ };
18
+
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const arg = argv[index];
21
+ if (arg === "--mode") {
22
+ options.mode = argv[index + 1] ?? "full";
23
+ index += 1;
24
+ continue;
25
+ }
26
+ if (arg === "--init") {
27
+ options.init = true;
28
+ continue;
29
+ }
30
+ if (arg === "--force") {
31
+ options.force = true;
32
+ continue;
33
+ }
34
+ if (!arg.startsWith("--") && !options.name) {
35
+ options.name = arg;
36
+ }
37
+ }
38
+
39
+ return options;
40
+ }
41
+
42
+ function ensureMode(mode) {
43
+ if (!["full", "api", "docs"].includes(mode)) {
44
+ console.error(`Unsupported mode: ${mode}`);
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ function listFiles(dir, prefix = "") {
50
+ const entries = readdirSync(dir);
51
+ return entries.flatMap((entry) => {
52
+ const absolute = path.join(dir, entry);
53
+ const relative = path.join(prefix, entry);
54
+ if (statSync(absolute).isDirectory()) {
55
+ return listFiles(absolute, relative);
56
+ }
57
+ return [relative];
58
+ });
59
+ }
60
+
61
+ function renderTemplate(content, projectName) {
62
+ return content.replaceAll("__PROJECT_NAME__", projectName);
63
+ }
64
+
65
+ function copyTemplateTree(sourceDir, targetDir, projectName, force) {
66
+ mkdirSync(targetDir, { recursive: true });
67
+ const files = listFiles(sourceDir);
68
+
69
+ for (const relativeFile of files) {
70
+ const sourcePath = path.join(sourceDir, relativeFile);
71
+ const targetPath = path.join(targetDir, relativeFile);
72
+ const targetFolder = path.dirname(targetPath);
73
+ mkdirSync(targetFolder, { recursive: true });
74
+
75
+ if (existsSync(targetPath) && !force) {
76
+ console.error(`Refusing to overwrite existing file: ${targetPath}`);
77
+ console.error("Use --force if you want to replace scaffolded files.");
78
+ process.exit(1);
79
+ }
80
+
81
+ const content = readFileSync(sourcePath, "utf8");
82
+ writeFileSync(targetPath, renderTemplate(content, projectName), "utf8");
83
+ }
84
+ }
85
+
86
+ function main() {
87
+ const options = parseArgs(args);
88
+ ensureMode(options.mode);
89
+
90
+ const inferredName = options.name ? path.basename(path.resolve(process.cwd(), options.name)) : path.basename(process.cwd());
91
+ const projectName = inferredName || "task-ops-project";
92
+ const targetDir = options.init
93
+ ? process.cwd()
94
+ : path.resolve(process.cwd(), options.name ?? projectName);
95
+
96
+ if (!options.init && existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
97
+ console.error(`Target directory is not empty: ${targetDir}`);
98
+ console.error("Use --force if you want to write scaffold files there.");
99
+ process.exit(1);
100
+ }
101
+
102
+ mkdirSync(targetDir, { recursive: true });
103
+
104
+ const commonDir = path.join(templatesRoot, "common");
105
+ const modeDir = path.join(templatesRoot, options.mode);
106
+
107
+ copyTemplateTree(commonDir, targetDir, projectName, options.force);
108
+ copyTemplateTree(modeDir, targetDir, projectName, options.force);
109
+
110
+ console.log(`create-task-ops completed`);
111
+ console.log(`target: ${targetDir}`);
112
+ console.log(`mode: ${options.mode}`);
113
+ console.log(`project: ${projectName}`);
114
+ console.log(`next steps:`);
115
+ console.log(`1. cd ${options.init ? "." : projectName}`);
116
+ if (options.mode !== "docs") {
117
+ console.log(`2. npm install`);
118
+ console.log(`3. npm run dev`);
119
+ } else {
120
+ console.log(`2. create real task files under tasks/`);
121
+ }
122
+ }
123
+
124
+ main();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "create-task-ops",
3
+ "version": "0.1.0",
4
+ "description": "Next.js-first task-ops scaffold generator for task docs and task APIs",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "bin",
9
+ "templates",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "bin": {
14
+ "create-task-ops": "./bin/create-task-ops.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.17.0"
18
+ },
19
+ "keywords": [
20
+ "nextjs",
21
+ "scaffold",
22
+ "generator",
23
+ "tasks",
24
+ "dashboard",
25
+ "llm",
26
+ "agents"
27
+ ],
28
+ "scripts": {
29
+ "pack:dry-run": "npm_config_cache=/tmp/npm-cache npm pack --dry-run",
30
+ "smoke:full": "node ./bin/create-task-ops.js /tmp/create-task-ops-full --mode full --force",
31
+ "smoke:api": "node ./bin/create-task-ops.js /tmp/create-task-ops-api --mode api --force",
32
+ "smoke:docs": "node ./bin/create-task-ops.js /tmp/create-task-ops-docs --mode docs --force",
33
+ "prepublishOnly": "npm run smoke:docs && npm run pack:dry-run"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ return NextResponse.json({
8
+ ok: true,
9
+ service: "__PROJECT_NAME__-task-api",
10
+ updatedAt: new Date().toISOString(),
11
+ });
12
+ }
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getTaskById } from "@/lib/task-api";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(
8
+ _request: Request,
9
+ context: { params: Promise<{ id: string }> },
10
+ ) {
11
+ const { id } = await context.params;
12
+ const task = await getTaskById(id);
13
+
14
+ if (!task) {
15
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
16
+ }
17
+
18
+ return NextResponse.json({
19
+ source: {
20
+ id: "__PROJECT_NAME__",
21
+ label: "__PROJECT_NAME__",
22
+ },
23
+ task,
24
+ });
25
+ }
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getTasks } from "@/lib/task-api";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET() {
8
+ const tasks = await getTasks();
9
+
10
+ return NextResponse.json({
11
+ source: {
12
+ id: "__PROJECT_NAME__",
13
+ label: "__PROJECT_NAME__",
14
+ },
15
+ tasks,
16
+ });
17
+ }
@@ -0,0 +1,10 @@
1
+ html,
2
+ body {
3
+ margin: 0;
4
+ padding: 0;
5
+ }
6
+
7
+ body {
8
+ background: #f6f2eb;
9
+ color: #20160f;
10
+ }
@@ -0,0 +1,13 @@
1
+ import "./globals.css";
2
+
3
+ export default function RootLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>) {
8
+ return (
9
+ <html lang="ko">
10
+ <body>{children}</body>
11
+ </html>
12
+ );
13
+ }
@@ -0,0 +1,8 @@
1
+ export default function Home() {
2
+ return (
3
+ <main style={{ padding: 32, fontFamily: "sans-serif" }}>
4
+ <h1>__PROJECT_NAME__ Task API</h1>
5
+ <p>`/api/health`, `/api/tasks`, `/api/tasks/:id` 를 통해 중앙 대시보드와 연동한다.</p>
6
+ </main>
7
+ );
8
+ }
@@ -0,0 +1,110 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type TaskStatus = "running" | "blocked" | "idle" | "queued" | "completed" | "failed";
5
+
6
+ type TaskRecord = {
7
+ id: string;
8
+ title: string;
9
+ status: TaskStatus;
10
+ progress: number;
11
+ progressNote: string;
12
+ priority: string;
13
+ owner: string;
14
+ agent: string;
15
+ lastUpdated: string;
16
+ goal: string;
17
+ currentState: string[];
18
+ completed: string[];
19
+ remaining: string[];
20
+ blockers: string[];
21
+ nextAction: string;
22
+ };
23
+
24
+ const tasksDir = path.join(process.cwd(), "tasks");
25
+ const sectionNames = [
26
+ "Metadata",
27
+ "Goal",
28
+ "Progress Model",
29
+ "Current State",
30
+ "Completed",
31
+ "Remaining",
32
+ "Blockers",
33
+ "Next Action",
34
+ "Notes",
35
+ ] as const;
36
+
37
+ function parseSection(markdown: string, heading: (typeof sectionNames)[number]) {
38
+ const names = sectionNames.join("|");
39
+ const pattern = new RegExp(`## ${heading}\\n([\\s\\S]*?)(?=\\n## (${names})\\n|$)`);
40
+ const match = markdown.match(pattern);
41
+ return match?.[1]?.trim() ?? "";
42
+ }
43
+
44
+ function parseKeyValueSection(section: string) {
45
+ return Object.fromEntries(
46
+ section
47
+ .split("\n")
48
+ .map((line) => line.trim())
49
+ .filter(Boolean)
50
+ .map((line) => line.replace(/^- /, ""))
51
+ .map((line) => {
52
+ const index = line.indexOf(":");
53
+ if (index === -1) {
54
+ return [line, ""];
55
+ }
56
+ return [line.slice(0, index).trim(), line.slice(index + 1).trim()];
57
+ }),
58
+ );
59
+ }
60
+
61
+ function parseList(section: string) {
62
+ return section
63
+ .split("\n")
64
+ .map((line) => line.trim())
65
+ .filter(Boolean)
66
+ .map((line) => line.replace(/^[-*] /, "").replace(/^\[[ x]\] /, ""));
67
+ }
68
+
69
+ function parseTitle(markdown: string) {
70
+ const match = markdown.match(/^# Task: (.+)$/m);
71
+ return match?.[1]?.trim() ?? "Untitled Task";
72
+ }
73
+
74
+ export async function getTasks(): Promise<TaskRecord[]> {
75
+ const entries = await readdir(tasksDir, { withFileTypes: true });
76
+ const taskFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".md"));
77
+
78
+ return Promise.all(
79
+ taskFiles.map(async (entry) => {
80
+ const fullPath = path.join(tasksDir, entry.name);
81
+ const markdown = await readFile(fullPath, "utf8");
82
+ const metadata = parseKeyValueSection(parseSection(markdown, "Metadata"));
83
+ const progressModel = parseKeyValueSection(parseSection(markdown, "Progress Model"));
84
+ const progress = Number.parseInt(progressModel.Progress ?? "0", 10);
85
+
86
+ return {
87
+ id: entry.name.replace(/\.md$/, ""),
88
+ title: parseTitle(markdown),
89
+ status: (metadata.Status ?? "idle") as TaskStatus,
90
+ progress: Number.isNaN(progress) ? 0 : progress,
91
+ progressNote: `${progressModel["Completed Checkpoints"] ?? "0"}/${progressModel["Total Checkpoints"] ?? "0"} checkpoints`,
92
+ priority: metadata.Priority ?? "medium",
93
+ owner: metadata.Owner ?? "Unassigned",
94
+ agent: metadata.Agent ?? "unknown",
95
+ lastUpdated: metadata["Last Updated"] ?? "",
96
+ goal: parseSection(markdown, "Goal"),
97
+ currentState: parseList(parseSection(markdown, "Current State")),
98
+ completed: parseList(parseSection(markdown, "Completed")),
99
+ remaining: parseList(parseSection(markdown, "Remaining")),
100
+ blockers: parseList(parseSection(markdown, "Blockers")),
101
+ nextAction: parseSection(markdown, "Next Action"),
102
+ };
103
+ }),
104
+ );
105
+ }
106
+
107
+ export async function getTaskById(id: string) {
108
+ const tasks = await getTasks();
109
+ return tasks.find((task) => task.id === id) ?? null;
110
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ reactStrictMode: true,
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start"
9
+ },
10
+ "dependencies": {
11
+ "next": "15.2.4",
12
+ "react": "19.0.0",
13
+ "react-dom": "19.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "22.13.10",
17
+ "@types/react": "19.0.10",
18
+ "@types/react-dom": "19.0.4",
19
+ "typescript": "5.8.2"
20
+ }
21
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,15 @@
1
+ # AGENT.md
2
+
3
+ ## Agent Checklist
4
+
5
+ 1. 관련 `tasks/*.md` 확인
6
+ 2. `Status`, `Progress Model`, `Current State`, `Next Action` 확인
7
+ 3. 작업 진행
8
+ 4. 의미 있는 체크포인트마다 task 갱신
9
+ 5. handoff 전 task 최신화
10
+
11
+ ## Progress Standard
12
+
13
+ - `Progress` 는 체크포인트 기반으로 기록한다.
14
+ - `Current State` 는 지금 사실인 상태를 쓴다.
15
+ - `Next Action` 은 다음 세션이 바로 실행할 수 있어야 한다.
@@ -0,0 +1,24 @@
1
+ # CLAUDE.md
2
+
3
+ ## Task-First Rule
4
+
5
+ - 이 리포의 작업 상태는 `tasks/*.md` 가 기준이다.
6
+ - 새 Claude 세션은 작업 시작 전에 관련 task 파일을 먼저 읽는다.
7
+ - 코드 변경, 테스트 결과, blocker 변화가 생기면 task 파일을 즉시 갱신한다.
8
+
9
+ ## Required Sections
10
+
11
+ - `Metadata`
12
+ - `Goal`
13
+ - `Progress Model`
14
+ - `Current State`
15
+ - `Completed`
16
+ - `Remaining`
17
+ - `Blockers`
18
+ - `Next Action`
19
+ - `Notes`
20
+
21
+ ## Handoff Requirement
22
+
23
+ - 다음 세션이 chat history 없이도 `tasks/*.md` 만 보고 이어받을 수 있어야 한다.
24
+ - handoff 전에 `Status`, `Progress`, `Current State`, `Blockers`, `Next Action`, `Last Updated` 를 최신으로 맞춘다.
@@ -0,0 +1,32 @@
1
+ # Task API Contract
2
+
3
+ 중앙 대시보드가 외부 리포를 읽기 위해 기대하는 최소 계약이다.
4
+
5
+ ## Endpoints
6
+
7
+ - `GET /api/health`
8
+ - `GET /api/tasks`
9
+ - `GET /api/tasks/:id`
10
+
11
+ ## Task Fields
12
+
13
+ - `id`
14
+ - `title`
15
+ - `status`
16
+ - `progress`
17
+ - `progressNote`
18
+ - `priority`
19
+ - `owner`
20
+ - `agent`
21
+ - `lastUpdated`
22
+ - `goal`
23
+ - `currentState`
24
+ - `completed`
25
+ - `remaining`
26
+ - `blockers`
27
+ - `nextAction`
28
+
29
+ ## Notes
30
+
31
+ - `tasks/*.md` 가 1차 진실 소스다.
32
+ - API는 그 문서를 읽어 중앙 대시보드에 노출하기 위한 계층이다.
@@ -0,0 +1,38 @@
1
+ # Task: __PROJECT_NAME__ Example Task
2
+
3
+ ## Metadata
4
+ - Owner: Core Team
5
+ - Agent: codex
6
+ - Status: running
7
+ - Progress: 25%
8
+ - Priority: high
9
+ - Last Updated: 2026-04-09T10:00:00+09:00
10
+
11
+ ## Goal
12
+ 이 리포에서 task-ops 운영 구조를 정착시키고 첫 작업 흐름을 검증한다.
13
+
14
+ ## Progress Model
15
+ - Total Checkpoints: 4
16
+ - Completed Checkpoints: 1
17
+ - Progress: 25%
18
+
19
+ ## Current State
20
+ - task-ops scaffold 가 배치되었다
21
+ - 첫 task 문서가 생성되었다
22
+
23
+ ## Completed
24
+ - [x] scaffold 생성
25
+
26
+ ## Remaining
27
+ - [ ] 실제 프로젝트 task 생성
28
+ - [ ] API source id와 label 확정
29
+ - [ ] 중앙 대시보드 등록
30
+
31
+ ## Blockers
32
+ - None
33
+
34
+ ## Next Action
35
+ 현재 프로젝트에 맞는 실제 작업 파일로 example task를 대체한다.
36
+
37
+ ## Notes
38
+ - 생성 직후에는 source metadata 를 팀 규약에 맞게 바꾼다.
@@ -0,0 +1,16 @@
1
+ # __PROJECT_NAME__
2
+
3
+ Task-ops 문서 모드로 생성된 프로젝트다.
4
+
5
+ ## 포함 항목
6
+
7
+ - `tasks/`
8
+ - `CLAUDE.md`
9
+ - `AGENT.md`
10
+ - `docs/TASK_API_CONTRACT.md`
11
+
12
+ ## 다음 단계
13
+
14
+ 1. `tasks/example-task.md` 를 실제 작업으로 교체
15
+ 2. 리포 규약에 맞게 `CLAUDE.md`, `AGENT.md` 조정
16
+ 3. 필요해지면 Next.js Task API를 추가
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ export const runtime = "nodejs";
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ return NextResponse.json({
8
+ ok: true,
9
+ service: "__PROJECT_NAME__-task-api",
10
+ updatedAt: new Date().toISOString(),
11
+ });
12
+ }
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getTaskById } from "@/lib/task-api";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(
8
+ _request: Request,
9
+ context: { params: Promise<{ id: string }> },
10
+ ) {
11
+ const { id } = await context.params;
12
+ const task = await getTaskById(id);
13
+
14
+ if (!task) {
15
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
16
+ }
17
+
18
+ return NextResponse.json({
19
+ source: {
20
+ id: "__PROJECT_NAME__",
21
+ label: "__PROJECT_NAME__",
22
+ },
23
+ task,
24
+ });
25
+ }
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getTasks } from "@/lib/task-api";
3
+
4
+ export const runtime = "nodejs";
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET() {
8
+ const tasks = await getTasks();
9
+
10
+ return NextResponse.json({
11
+ source: {
12
+ id: "__PROJECT_NAME__",
13
+ label: "__PROJECT_NAME__",
14
+ },
15
+ tasks,
16
+ });
17
+ }
@@ -0,0 +1,48 @@
1
+ body {
2
+ margin: 0;
3
+ background: #f6f2eb;
4
+ color: #20160f;
5
+ font-family: Georgia, "Times New Roman", serif;
6
+ }
7
+
8
+ .page-shell {
9
+ min-height: 100vh;
10
+ padding: 40px 20px;
11
+ }
12
+
13
+ .hero,
14
+ .task-grid {
15
+ width: min(960px, 100%);
16
+ margin: 0 auto;
17
+ }
18
+
19
+ .hero {
20
+ margin-bottom: 24px;
21
+ }
22
+
23
+ .hero-card,
24
+ .task-card {
25
+ border: 1px solid rgba(91, 64, 34, 0.14);
26
+ border-radius: 24px;
27
+ background: rgba(255, 250, 242, 0.92);
28
+ padding: 24px;
29
+ }
30
+
31
+ .eyebrow {
32
+ color: #d46a32;
33
+ font-size: 0.78rem;
34
+ font-weight: 700;
35
+ text-transform: uppercase;
36
+ letter-spacing: 0.12em;
37
+ }
38
+
39
+ .task-grid {
40
+ display: grid;
41
+ gap: 16px;
42
+ }
43
+
44
+ .task-head {
45
+ display: flex;
46
+ justify-content: space-between;
47
+ gap: 12px;
48
+ }
@@ -0,0 +1,13 @@
1
+ import "./globals.css";
2
+
3
+ export default function RootLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>) {
8
+ return (
9
+ <html lang="ko">
10
+ <body>{children}</body>
11
+ </html>
12
+ );
13
+ }
@@ -0,0 +1,37 @@
1
+ async function getTasks() {
2
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
3
+ const response = await fetch(`${baseUrl}/api/tasks`, { cache: "no-store" }).catch(() => null);
4
+ if (!response?.ok) {
5
+ return [];
6
+ }
7
+ const payload = (await response.json()) as { tasks?: Array<{ id: string; title: string; status: string; progress: number; nextAction: string }> };
8
+ return payload.tasks ?? [];
9
+ }
10
+
11
+ export default async function Home() {
12
+ const tasks = await getTasks();
13
+
14
+ return (
15
+ <main className="page-shell">
16
+ <section className="hero">
17
+ <div className="hero-card">
18
+ <span className="eyebrow">Task Ops</span>
19
+ <h1>__PROJECT_NAME__</h1>
20
+ <p>이 프로젝트는 task-first 운영 구조와 Next.js 기반 Task API를 기본으로 시작한다.</p>
21
+ </div>
22
+ </section>
23
+ <section className="task-grid">
24
+ {tasks.map((task) => (
25
+ <article className="task-card" key={task.id}>
26
+ <div className="task-head">
27
+ <strong>{task.title}</strong>
28
+ <span>{task.status}</span>
29
+ </div>
30
+ <p>{task.progress}%</p>
31
+ <small>{task.nextAction}</small>
32
+ </article>
33
+ ))}
34
+ </section>
35
+ </main>
36
+ );
37
+ }
@@ -0,0 +1,110 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type TaskStatus = "running" | "blocked" | "idle" | "queued" | "completed" | "failed";
5
+
6
+ type TaskRecord = {
7
+ id: string;
8
+ title: string;
9
+ status: TaskStatus;
10
+ progress: number;
11
+ progressNote: string;
12
+ priority: string;
13
+ owner: string;
14
+ agent: string;
15
+ lastUpdated: string;
16
+ goal: string;
17
+ currentState: string[];
18
+ completed: string[];
19
+ remaining: string[];
20
+ blockers: string[];
21
+ nextAction: string;
22
+ };
23
+
24
+ const tasksDir = path.join(process.cwd(), "tasks");
25
+ const sectionNames = [
26
+ "Metadata",
27
+ "Goal",
28
+ "Progress Model",
29
+ "Current State",
30
+ "Completed",
31
+ "Remaining",
32
+ "Blockers",
33
+ "Next Action",
34
+ "Notes",
35
+ ] as const;
36
+
37
+ function parseSection(markdown: string, heading: (typeof sectionNames)[number]) {
38
+ const names = sectionNames.join("|");
39
+ const pattern = new RegExp(`## ${heading}\\n([\\s\\S]*?)(?=\\n## (${names})\\n|$)`);
40
+ const match = markdown.match(pattern);
41
+ return match?.[1]?.trim() ?? "";
42
+ }
43
+
44
+ function parseKeyValueSection(section: string) {
45
+ return Object.fromEntries(
46
+ section
47
+ .split("\n")
48
+ .map((line) => line.trim())
49
+ .filter(Boolean)
50
+ .map((line) => line.replace(/^- /, ""))
51
+ .map((line) => {
52
+ const index = line.indexOf(":");
53
+ if (index === -1) {
54
+ return [line, ""];
55
+ }
56
+ return [line.slice(0, index).trim(), line.slice(index + 1).trim()];
57
+ }),
58
+ );
59
+ }
60
+
61
+ function parseList(section: string) {
62
+ return section
63
+ .split("\n")
64
+ .map((line) => line.trim())
65
+ .filter(Boolean)
66
+ .map((line) => line.replace(/^[-*] /, "").replace(/^\[[ x]\] /, ""));
67
+ }
68
+
69
+ function parseTitle(markdown: string) {
70
+ const match = markdown.match(/^# Task: (.+)$/m);
71
+ return match?.[1]?.trim() ?? "Untitled Task";
72
+ }
73
+
74
+ export async function getTasks(): Promise<TaskRecord[]> {
75
+ const entries = await readdir(tasksDir, { withFileTypes: true });
76
+ const taskFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".md"));
77
+
78
+ return Promise.all(
79
+ taskFiles.map(async (entry) => {
80
+ const fullPath = path.join(tasksDir, entry.name);
81
+ const markdown = await readFile(fullPath, "utf8");
82
+ const metadata = parseKeyValueSection(parseSection(markdown, "Metadata"));
83
+ const progressModel = parseKeyValueSection(parseSection(markdown, "Progress Model"));
84
+ const progress = Number.parseInt(progressModel.Progress ?? "0", 10);
85
+
86
+ return {
87
+ id: entry.name.replace(/\.md$/, ""),
88
+ title: parseTitle(markdown),
89
+ status: (metadata.Status ?? "idle") as TaskStatus,
90
+ progress: Number.isNaN(progress) ? 0 : progress,
91
+ progressNote: `${progressModel["Completed Checkpoints"] ?? "0"}/${progressModel["Total Checkpoints"] ?? "0"} checkpoints`,
92
+ priority: metadata.Priority ?? "medium",
93
+ owner: metadata.Owner ?? "Unassigned",
94
+ agent: metadata.Agent ?? "unknown",
95
+ lastUpdated: metadata["Last Updated"] ?? "",
96
+ goal: parseSection(markdown, "Goal"),
97
+ currentState: parseList(parseSection(markdown, "Current State")),
98
+ completed: parseList(parseSection(markdown, "Completed")),
99
+ remaining: parseList(parseSection(markdown, "Remaining")),
100
+ blockers: parseList(parseSection(markdown, "Blockers")),
101
+ nextAction: parseSection(markdown, "Next Action"),
102
+ };
103
+ }),
104
+ );
105
+ }
106
+
107
+ export async function getTaskById(id: string) {
108
+ const tasks = await getTasks();
109
+ return tasks.find((task) => task.id === id) ?? null;
110
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ reactStrictMode: true,
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start"
9
+ },
10
+ "dependencies": {
11
+ "next": "15.2.4",
12
+ "react": "19.0.0",
13
+ "react-dom": "19.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "22.13.10",
17
+ "@types/react": "19.0.10",
18
+ "@types/react-dom": "19.0.4",
19
+ "typescript": "5.8.2"
20
+ }
21
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }