Commit 5a49060364a6948ec560010cd491438347838fa6

Authored by bboonnee
1 parent 1a31a2ad

U-002: target-project .graphlemon/ scaffold (c_project_create)

Implements c_project_create (03-C §3.0) as a plain function for U-007 to
wire up later: idempotent by target_folder_path, creates .graphlemon/ +
one init commit in an existing git repo, PATH_NOT_FOUND/PATH_NOT_WRITABLE/
GIT_INIT_DECLINED per contract, never silently `git init`s (02-V §2.1).

Also corrects a real SDD-vs-plan conflict found while scoping this Unit:
development-plan.md's U-002 line cited architecture doc §7.3's directory
tree (.claude/agents/, skills/, top-level runs/) as what to create inside
a target project. But §13.1 (a later, confirmed decision) states those are
graphlemon's own platform-repo assets (U-003 scope), and 01-M's own
graphlemon_subdir_initialized field already points at §13.2, whose target-
project tree is only .git/ + .graphlemon/ + the target's pre-existing
src/. §7.3 predates the "target project is an independent code space"
pivot and was superseded by it. Corrected development-plan.md,
unit_matrix.json, CLAUDE.md, and 05-实施约束与依赖.md accordingly (user
confirmed, 2026-07-31).

23 stdlib unittest cases (src/test_schema.py + new src/test_project_scaffold.py),
all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md
... ... @@ -115,7 +115,9 @@ directly; anything worth reusing has already been copied and adapted into this r
115 115 - `supervisor/isolation/` — staged utility library (isolate/diff/apply), not yet wired to anything
116 116 - `spikes/` — one-off validation spikes (currently just A-1)
117 117 - `src/` (created by U-001) — graphlemon's own backend source (schema, then U-007+ HTTP/WebSocket server).
118   - **Not the same `src/` U-002 generates inside a target project's own repo** — those are two different
119   - physical repos that can coexist on disk during PH5 self-target. See 05-实施约束与依赖.md §5.7.
  118 + **Not the same `src/` a target project already has in its own repo** — a target project's `src/` is
  119 + its own pre-existing source tree; graphlemon does not create or restructure it (see 05-实施约束与依赖.md
  120 + §5.7 and architecture doc §13.2 — U-002 only creates `.graphlemon/` inside a target project, corrected
  121 + 2026-07-31 after finding development-plan.md's U-002 line had cited the wrong architecture section).
120 122 - Everything else (`.claude/agents/`, `skills/`, `runs/`, per-target-project scaffolding) gets created by
121 123 U-002/U-003 — it doesn't exist yet, and that's expected at this stage.
... ...
docs/development-plan.md
... ... @@ -48,9 +48,10 @@
48 48 - 覆盖:01-M 全部实体
49 49 - 源码位置:graphlemon 仓库根目录 `src/`(见 05 节 §5.7"源码目录约定",与 U-002 在目标项目里生成的 `src/` 是两回事)
50 50  
51   -- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑(架构文档 §7.3 目录树:`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、`runs/`、`src/`)+ `.graphlemon/` 子目录(架构文档 §13.2)
  51 +- **U-002 [Enabler]** 目标项目 Git 仓库目录骨架初始化逻辑:只生成 `.graphlemon/` 子目录(含 `runs/<task_id>/`,架构文档 §13.2——01-M `project.graphlemon_subdir_initialized` 字段本身指向的就是这节,不是 §7.3)
  52 + - **2026-07-31 订正**:development-plan.md 这行原先误引用了架构文档 §7.3 的旧目录树(`.claude/agents/`、`.claude/agent-memory/`、`.claude/workflows/`、`skills/`、顶层 `runs/`)。但 §13.1("已确认"决策,晚于 §7.3)明确写着这些是 graphlemon **平台自己仓库**的资产("graphlemon 平台自己的仓库只保留平台资产:Org/Work Graph 引擎、Skill 库、Agent 人设...不含任何目标项目的业务代码"),属于 U-003 范围,不是目标项目、不是 U-002。§7.3 是"graphlemon 只服务自己开发自己"阶段(自举模式)的旧设计,已被 §13"目标项目是独立代码空间"这个决策取代。目标项目自己的 `src/` 不需要 graphlemon 创建或规定结构("目标项目自己原有的源码目录,graphlemon 不规定具体结构,尊重目标项目原有约定")。用户确认,2026-07-31。
52 53 - 依赖:U-001(`project.graphlemon_subdir_initialized` 字段需要有实体支撑)
53   - - Done:给定一个空 git 仓库,能生成完整目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`)
  54 + - Done:给定一个空 git 仓库,能生成 `.graphlemon/` 子目录骨架并产生一次初始化 commit;非 git 仓库时按 02-V 2.1 节的确认态流程走(不静默 `git init`)
54 55 - 覆盖:01-M `project`;03-C `c_project_create`
55 56  
56 57 - **U-003 [Enabler]** graphlemon 平台自身的仓库/运行时目录结构(与目标项目仓库物理分离;本地非 Git 的原始日志目录,见架构文档 §13.2)
... ...
docs/sdd/05-实施约束与依赖.md
... ... @@ -68,7 +68,7 @@
68 68 - 前端是本地 Web 应用(浏览器页面),后端用 `http.server`/`socketserver` 等 stdlib 原生能力提供 HTTP API;WebSocket 若 stdlib 支持不足,实施时(U-007)需要验证并可退化为 SSE/长轮询,但仍在 stdlib 范围内解决,不引入第三方包;前端不引入 npm/构建步骤,可以是原生 HTML/CSS/JS 或参考 graphloop `graph.html`/`project.html` 的单文件 no-build 页面模式
69 69 - **例外**:这条约束只针对 graphlemon 平台自身的实现语言,不代表 graphlemon 生成/审查的目标项目代码也必须是 Python——目标项目用什么语言由目标项目自己的技术栈决定
70 70 - **影响**:development-plan.md 的 U-007(本地后端骨架)、U-008(前端骨架)、U-036(Deploy Supervisor 骨架)的 Done 标准隐含"纯 stdlib"这条约束
71   -- **源码目录约定(2026-07-30 补充)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与 U-002 在**目标项目**仓库内生成的 `src/` 是两个完全不同的目录(分属两个物理仓库,见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分
  71 +- **源码目录约定(2026-07-30 补充,2026-07-31 订正)**:graphlemon 平台自身的后端源码(U-001 的 schema 实现、U-007 起的 HTTP/WebSocket 后端)落在 graphlemon 仓库根目录的 `src/`。这与**目标项目**仓库自己已有的 `src/` 是两个完全不同的目录,分属两个物理仓库(见架构文档 §13.1"目标项目是独立代码空间"决策)——PH5 self-target 场景下二者会同时存在于同一台机器上,实现时不要混淆,必要时以绝对路径而非相对路径区分。**订正**:目标项目的 `src/` 不是 U-002 生成的——U-002 只在目标项目里创建 `.graphlemon/` 子目录(架构文档 §13.2),目标项目自己的 `src/` 是其原有源码目录,graphlemon 不创建也不规定其结构;development-plan.md 此前 U-002 那行误引用了架构文档 §7.3 的旧目录树,已于 2026-07-31 订正(§7.3 是"graphlemon 只服务自己开发自己"阶段的旧设计,已被 §13 取代,§7.3 里的 `.claude/agents/`、`skills/`、顶层 `runs/` 实际是 graphlemon 平台自己仓库的资产,属于 U-003 范围)
72 72  
73 73 ## 5.8 self-target 场景治理规则(`self-development-feasibility.md` 缺口 1/3,2026-07-30 回填)
74 74  
... ...
docs/sdd/unit_matrix.json
... ... @@ -12,7 +12,7 @@
12 12 },
13 13 "units": [
14 14 {"id": "U-001", "phase": "PH0", "sub_phase": "PH0-S1", "name": "01-M 全部 15 实体物理 schema", "classification": "Enabler", "depends_on": [], "coverage": {"model": ["project", "org_graph_definition", "task", "work_graph_run", "work_node_execution", "artifact", "bug_record", "skill", "skill_iteration", "agent_profile", "budget_guardrail_config", "org_concurrency_config", "execution_event", "execution_log_file", "log_retention_config"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "15 张表全部建表成功,关键约束(唯一/外键/枚举)有单测覆盖"},
15   - {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成完整目录骨架并产生初始化 commit"},
  15 + {"id": "U-002", "phase": "PH0", "sub_phase": "PH0-S1", "name": "目标项目 Git 仓库目录骨架初始化(仅 .graphlemon/,见架构文档 §13.2;2026-07-31 订正,此前误引用 §7.3)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["project"], "view": [], "controller": ["c_project_create"], "process": [], "requirement": []}, "done": "给定空 git 仓库,能生成 .graphlemon/ 子目录骨架并产生初始化 commit"},
16 16 {"id": "U-003", "phase": "PH0", "sub_phase": "PH0-S1", "name": "graphlemon 平台自身仓库/运行时目录结构", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "平台目录与目标项目目录物理分离,互不干扰"},
17 17 {"id": "U-004", "phase": "PH0", "sub_phase": "PH0-S2", "name": "Hermes profile 迁移到 .claude/agents/*.md", "classification": "Risk Gate", "depends_on": ["U-002"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "claude --agent planner -p 可正常启动且系统提示词正确;skills/memory/tools/model frontmatter 配置完整"},
18 18 {"id": "U-004.5", "phase": "PH0", "sub_phase": "PH0-S2", "name": "RUN_REAL_AGENT_E2E 门控测试 harness(2026-07-30 新增)", "classification": "Enabler", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "有可复用的 skip_unless_env(\"RUN_REAL_AGENT_E2E\") 装饰器/基类;默认 unittest 跳过真实 claude 子进程调用;显式开启环境变量时至少一个真实往返用例可跑通"},
... ...
src/project_scaffold.py 0 → 100644
  1 +"""c_project_create (03-C §3.0): register/scaffold a target project.
  2 +
  3 +U-002 scope: the directory-scaffolding + DB-persistence half of c_project_create --
  4 +no HTTP endpoint yet (that's U-007); this is the plain function U-007 will wire up.
  5 +
  6 +Target-project directory convention is architecture doc §13.2, NOT §7.3 (see
  7 +development-plan.md's 2026-07-31 correction: §7.3 describes graphlemon's own
  8 +platform repo, not a target project). c_project_create only ever creates
  9 +`.graphlemon/` inside the target folder; a target project's own `src/` and
  10 +everything else in it is left untouched.
  11 +"""
  12 +
  13 +import os
  14 +import subprocess
  15 +import uuid
  16 +
  17 +_GIT_IDENTITY = [
  18 + "-c", "user.name=graphlemon",
  19 + "-c", "user.email=graphlemon@localhost",
  20 +]
  21 +
  22 +
  23 +class PathNotFoundError(Exception):
  24 + """PATH_NOT_FOUND -- 03-C §3.0: target_folder_path does not exist."""
  25 +
  26 +
  27 +class PathNotWritableError(Exception):
  28 + """PATH_NOT_WRITABLE -- 03-C §3.0: no write permission on target_folder_path."""
  29 +
  30 +
  31 +class GitInitDeclinedError(Exception):
  32 + """GIT_INIT_DECLINED -- 03-C §3.0: not a git repo and caller didn't ask to init one.
  33 +
  34 + 02-V §2.1: the UI must get explicit user confirmation before setting
  35 + init_git_if_missing=True; this function never silently runs `git init`.
  36 + """
  37 +
  38 +
  39 +def _run_git(args, cwd):
  40 + subprocess.run(["git"] + args, cwd=cwd, check=True, capture_output=True, text=True)
  41 +
  42 +
  43 +def create_project(conn, target_folder_path, name=None, init_git_if_missing=False):
  44 + """Implements c_project_create (03-C §3.0).
  45 +
  46 + Returns {"project_id", "has_git_repo", "graphlemon_subdir_initialized"}.
  47 + Idempotent: a second call with the same target_folder_path returns the
  48 + existing row untouched (01-M 1.0 幂等约束) -- no re-validation, no repeat
  49 + side effects, matching REQ-11's failure-case wording.
  50 + """
  51 + existing = conn.execute(
  52 + "SELECT project_id, has_git_repo, graphlemon_subdir_initialized "
  53 + "FROM project WHERE target_folder_path = ?",
  54 + (target_folder_path,),
  55 + ).fetchone()
  56 + if existing is not None:
  57 + return {
  58 + "project_id": existing[0],
  59 + "has_git_repo": bool(existing[1]),
  60 + "graphlemon_subdir_initialized": bool(existing[2]),
  61 + }
  62 +
  63 + if not os.path.isdir(target_folder_path):
  64 + raise PathNotFoundError(target_folder_path)
  65 + if not os.access(target_folder_path, os.W_OK):
  66 + raise PathNotWritableError(target_folder_path)
  67 +
  68 + has_git_repo = os.path.isdir(os.path.join(target_folder_path, ".git"))
  69 + if not has_git_repo:
  70 + if not init_git_if_missing:
  71 + raise GitInitDeclinedError(target_folder_path)
  72 + _run_git(["init"], cwd=target_folder_path)
  73 + has_git_repo = True
  74 +
  75 + graphlemon_subdir = os.path.join(target_folder_path, ".graphlemon")
  76 + os.makedirs(graphlemon_subdir, exist_ok=True)
  77 + # git doesn't track empty directories; this is only so the init commit has
  78 + # something under .graphlemon/ to record. Real content (runs/<task_id>/...)
  79 + # arrives once tasks actually execute (01-M 1.5 artifact.file_path).
  80 + open(os.path.join(graphlemon_subdir, ".gitkeep"), "w").close()
  81 +
  82 + _run_git(["add", ".graphlemon"], cwd=target_folder_path)
  83 + _run_git(
  84 + _GIT_IDENTITY + ["commit", "-m", "graphlemon: initialize .graphlemon/ workspace"],
  85 + cwd=target_folder_path,
  86 + )
  87 +
  88 + project_id = str(uuid.uuid4())
  89 + project_name = name or os.path.basename(os.path.normpath(target_folder_path))
  90 + conn.execute(
  91 + "INSERT INTO project "
  92 + "(project_id, name, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status) "
  93 + "VALUES (?, ?, ?, ?, 1, 'active')",
  94 + (project_id, project_name, target_folder_path, int(has_git_repo)),
  95 + )
  96 + conn.commit()
  97 +
  98 + return {
  99 + "project_id": project_id,
  100 + "has_git_repo": has_git_repo,
  101 + "graphlemon_subdir_initialized": True,
  102 + }
... ...
src/test_project_scaffold.py 0 → 100644
  1 +"""U-002 tests: c_project_create's directory-scaffolding + DB-persistence logic.
  2 +
  3 +Run: python3 -m unittest src.test_project_scaffold -v
  4 +"""
  5 +
  6 +import os
  7 +import shutil
  8 +import stat
  9 +import subprocess
  10 +import tempfile
  11 +import unittest
  12 +
  13 +from src import project_scaffold, schema
  14 +
  15 +
  16 +def _init_git_repo(path):
  17 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  18 +
  19 +
  20 +class ProjectScaffoldTestBase(unittest.TestCase):
  21 + def setUp(self):
  22 + self.conn = schema.connect(":memory:")
  23 + schema.create_schema(self.conn)
  24 + self.tmpdir = tempfile.mkdtemp(prefix="graphlemon-u002-")
  25 +
  26 + def tearDown(self):
  27 + self.conn.close()
  28 + shutil.rmtree(self.tmpdir, ignore_errors=True)
  29 +
  30 +
  31 +class HappyPathTest(ProjectScaffoldTestBase):
  32 + def test_existing_git_repo_gets_graphlemon_subdir_and_init_commit(self):
  33 + _init_git_repo(self.tmpdir)
  34 +
  35 + result = project_scaffold.create_project(self.conn, self.tmpdir)
  36 +
  37 + self.assertTrue(result["has_git_repo"])
  38 + self.assertTrue(result["graphlemon_subdir_initialized"])
  39 + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon")))
  40 +
  41 + log = subprocess.run(
  42 + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True
  43 + )
  44 + self.assertIn("graphlemon: initialize .graphlemon/ workspace", log.stdout)
  45 +
  46 + row = self.conn.execute(
  47 + "SELECT project_id, target_folder_path, has_git_repo, graphlemon_subdir_initialized, status "
  48 + "FROM project WHERE project_id = ?",
  49 + (result["project_id"],),
  50 + ).fetchone()
  51 + self.assertIsNotNone(row)
  52 + self.assertEqual(row[1], self.tmpdir)
  53 + self.assertEqual(row[2], 1)
  54 + self.assertEqual(row[3], 1)
  55 + self.assertEqual(row[4], "active")
  56 +
  57 + def test_project_name_defaults_to_folder_name(self):
  58 + _init_git_repo(self.tmpdir)
  59 + result = project_scaffold.create_project(self.conn, self.tmpdir)
  60 + row = self.conn.execute(
  61 + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],)
  62 + ).fetchone()
  63 + self.assertEqual(row[0], os.path.basename(self.tmpdir))
  64 +
  65 + def test_explicit_name_used_when_given(self):
  66 + _init_git_repo(self.tmpdir)
  67 + result = project_scaffold.create_project(self.conn, self.tmpdir, name="Snake Game")
  68 + row = self.conn.execute(
  69 + "SELECT name FROM project WHERE project_id = ?", (result["project_id"],)
  70 + ).fetchone()
  71 + self.assertEqual(row[0], "Snake Game")
  72 +
  73 +
  74 +class NonGitRepoConfirmationFlowTest(ProjectScaffoldTestBase):
  75 + """02-V §2.1 / 03-C §3.0: never silently `git init`."""
  76 +
  77 + def test_declines_without_confirmation_and_leaves_folder_untouched(self):
  78 + with self.assertRaises(project_scaffold.GitInitDeclinedError):
  79 + project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=False)
  80 +
  81 + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".git")))
  82 + self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon")))
  83 + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0]
  84 + self.assertEqual(count, 0)
  85 +
  86 + def test_initializes_git_when_explicitly_confirmed(self):
  87 + result = project_scaffold.create_project(self.conn, self.tmpdir, init_git_if_missing=True)
  88 +
  89 + self.assertTrue(result["has_git_repo"])
  90 + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".git")))
  91 + self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, ".graphlemon")))
  92 +
  93 +
  94 +class IdempotentCreationTest(ProjectScaffoldTestBase):
  95 + def test_second_call_returns_existing_record_without_new_commit(self):
  96 + _init_git_repo(self.tmpdir)
  97 + first = project_scaffold.create_project(self.conn, self.tmpdir)
  98 + second = project_scaffold.create_project(self.conn, self.tmpdir)
  99 +
  100 + self.assertEqual(first["project_id"], second["project_id"])
  101 + count = self.conn.execute("SELECT COUNT(*) FROM project").fetchone()[0]
  102 + self.assertEqual(count, 1)
  103 +
  104 + log = subprocess.run(
  105 + ["git", "log", "--oneline"], cwd=self.tmpdir, check=True, capture_output=True, text=True
  106 + )
  107 + self.assertEqual(len(log.stdout.strip().splitlines()), 1)
  108 +
  109 + def test_idempotent_hit_skips_path_validation(self):
  110 + # REQ-11 failure case: repeat calls on an already-registered path must
  111 + # return the existing record even if the path itself is no longer
  112 + # valid, not re-validate and error.
  113 + _init_git_repo(self.tmpdir)
  114 + first = project_scaffold.create_project(self.conn, self.tmpdir)
  115 + shutil.rmtree(self.tmpdir)
  116 +
  117 + second = project_scaffold.create_project(self.conn, self.tmpdir)
  118 + self.assertEqual(first["project_id"], second["project_id"])
  119 +
  120 +
  121 +class PathValidationErrorsTest(ProjectScaffoldTestBase):
  122 + def test_nonexistent_path_raises_path_not_found(self):
  123 + missing = os.path.join(self.tmpdir, "does-not-exist")
  124 + with self.assertRaises(project_scaffold.PathNotFoundError):
  125 + project_scaffold.create_project(self.conn, missing)
  126 +
  127 + def test_unwritable_path_raises_path_not_writable(self):
  128 + os.chmod(self.tmpdir, stat.S_IRUSR | stat.S_IXUSR)
  129 + try:
  130 + if os.access(self.tmpdir, os.W_OK):
  131 + self.skipTest("running as a user that bypasses permission bits (e.g. root)")
  132 + with self.assertRaises(project_scaffold.PathNotWritableError):
  133 + project_scaffold.create_project(self.conn, self.tmpdir)
  134 + finally:
  135 + os.chmod(self.tmpdir, stat.S_IRWXU)
  136 +
  137 +
  138 +if __name__ == "__main__":
  139 + unittest.main()
... ...