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 +21 -0
- package/README.md +60 -0
- package/bin/create-task-ops.js +124 -0
- package/package.json +38 -0
- package/templates/api/app/api/health/route.ts +12 -0
- package/templates/api/app/api/tasks/[id]/route.ts +25 -0
- package/templates/api/app/api/tasks/route.ts +17 -0
- package/templates/api/app/globals.css +10 -0
- package/templates/api/app/layout.tsx +13 -0
- package/templates/api/app/page.tsx +8 -0
- package/templates/api/lib/task-api.ts +110 -0
- package/templates/api/next-env.d.ts +2 -0
- package/templates/api/next.config.ts +7 -0
- package/templates/api/package.json +21 -0
- package/templates/api/tsconfig.json +23 -0
- package/templates/common/AGENT.md +15 -0
- package/templates/common/CLAUDE.md +24 -0
- package/templates/common/docs/TASK_API_CONTRACT.md +32 -0
- package/templates/common/tasks/example-task.md +38 -0
- package/templates/docs/README.md +16 -0
- package/templates/full/app/api/health/route.ts +12 -0
- package/templates/full/app/api/tasks/[id]/route.ts +25 -0
- package/templates/full/app/api/tasks/route.ts +17 -0
- package/templates/full/app/globals.css +48 -0
- package/templates/full/app/layout.tsx +13 -0
- package/templates/full/app/page.tsx +37 -0
- package/templates/full/lib/task-api.ts +110 -0
- package/templates/full/next-env.d.ts +2 -0
- package/templates/full/next.config.ts +7 -0
- package/templates/full/package.json +21 -0
- package/templates/full/tsconfig.json +23 -0
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,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,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,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,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
|
+
}
|