✨ feat(playbook): version durable scratch workflow state
This commit is contained in:
@@ -63,6 +63,7 @@ project_name = "MyProject"
|
|||||||
- **配置节存在即启用**:只写需要同步的配置节
|
- **配置节存在即启用**:只写需要同步的配置节
|
||||||
- **AGENTS.md**:始终按区块更新(`<!-- playbook:xxx:start/end -->`)
|
- **AGENTS.md**:始终按区块更新(`<!-- playbook:xxx:start/end -->`)
|
||||||
- **CLAUDE.md**:自动检测(根目录 → `.claude/`),不存在则创建;注入 `@AGENTS.md` / `@AGENT_RULES.md`
|
- **CLAUDE.md**:自动检测(根目录 → `.claude/`),不存在则创建;注入 `@AGENTS.md` / `@AGENT_RULES.md`
|
||||||
|
- **.gitignore**:启用 `[sync_rules]` 时更新 Playbook 标记区块,只忽略 `.scratch` 的锁、worktree 和临时文件
|
||||||
- **force**:默认 false,已存在则跳过;设为 true 时强制覆盖(会先备份)
|
- **force**:默认 false,已存在则跳过;设为 true 时强制覆盖(会先备份)
|
||||||
|
|
||||||
更多说明详见 [templates/README.md](templates/README.md)。
|
更多说明详见 [templates/README.md](templates/README.md)。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
[sync_rules]
|
[sync_rules]
|
||||||
# 同步 AGENT_RULES.md
|
# 同步 AGENT_RULES.md,并更新目标项目 .gitignore 中的 .scratch 运行时区块
|
||||||
# force = false # 覆盖已有文件
|
# force = false # 覆盖已有文件
|
||||||
# no_backup = false # 跳过备份
|
# no_backup = false # 跳过备份
|
||||||
# date = "2026-04-22" # 替换 {{DATE}}
|
# date = "2026-04-22" # 替换 {{DATE}}
|
||||||
|
|||||||
+75
-1
@@ -339,7 +339,12 @@ def install_snapshot(config: dict, context: dict) -> int:
|
|||||||
if templates_ci.is_dir():
|
if templates_ci.is_dir():
|
||||||
copytree(templates_ci, templates_dst / "ci")
|
copytree(templates_ci, templates_dst / "ci")
|
||||||
|
|
||||||
for name in ("AGENTS.template.md", "AGENT_RULES.template.md", "README.md"):
|
for name in (
|
||||||
|
"AGENTS.template.md",
|
||||||
|
"AGENT_RULES.template.md",
|
||||||
|
"gitignore.template",
|
||||||
|
"README.md",
|
||||||
|
):
|
||||||
src = templates_root / name
|
src = templates_root / name
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
copy2(src, templates_dst / name)
|
copy2(src, templates_dst / name)
|
||||||
@@ -510,6 +515,8 @@ _AGENTS_BLOCK_START = "<!-- playbook:agents:start -->"
|
|||||||
_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->"
|
_AGENTS_BLOCK_END = "<!-- playbook:agents:end -->"
|
||||||
_RULES_BLOCK_START = "<!-- playbook:rules:start -->"
|
_RULES_BLOCK_START = "<!-- playbook:rules:start -->"
|
||||||
_RULES_BLOCK_END = "<!-- playbook:rules:end -->"
|
_RULES_BLOCK_END = "<!-- playbook:rules:end -->"
|
||||||
|
_GITIGNORE_BLOCK_START = "# BEGIN playbook .scratch runtime"
|
||||||
|
_GITIGNORE_BLOCK_END = "# END playbook .scratch runtime"
|
||||||
|
|
||||||
|
|
||||||
def replace_marked_block(
|
def replace_marked_block(
|
||||||
@@ -751,6 +758,10 @@ def sync_rules_action(config: dict, context: dict) -> int:
|
|||||||
if not rules_src.is_file():
|
if not rules_src.is_file():
|
||||||
print(f"ERROR: template not found: {rules_src}", file=sys.stderr)
|
print(f"ERROR: template not found: {rules_src}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
gitignore_src = templates_dir / "gitignore.template"
|
||||||
|
if not gitignore_src.is_file():
|
||||||
|
print(f"ERROR: template not found: {gitignore_src}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
rules_dst = project_root / "AGENT_RULES.md"
|
rules_dst = project_root / "AGENT_RULES.md"
|
||||||
force = bool(config.get("force", False))
|
force = bool(config.get("force", False))
|
||||||
@@ -809,6 +820,16 @@ def sync_rules_action(config: dict, context: dict) -> int:
|
|||||||
)
|
)
|
||||||
log("Created: AGENT_RULES.local.md")
|
log("Created: AGENT_RULES.local.md")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sync_gitignore_block(
|
||||||
|
gitignore_src,
|
||||||
|
project_root / ".gitignore",
|
||||||
|
no_backup,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1046,6 +1067,59 @@ def sync_gitattributes_block(src: Path, dst: Path, no_backup: bool) -> None:
|
|||||||
log("Synced .gitattributes from standards (block).")
|
log("Synced .gitattributes from standards (block).")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_gitignore_block(src: Path, dst: Path, no_backup: bool) -> None:
|
||||||
|
source_lines = src.read_text(encoding="utf-8").splitlines()
|
||||||
|
block_lines = [
|
||||||
|
_GITIGNORE_BLOCK_START,
|
||||||
|
*source_lines,
|
||||||
|
_GITIGNORE_BLOCK_END,
|
||||||
|
]
|
||||||
|
|
||||||
|
original_text = dst.read_text(encoding="utf-8") if dst.exists() else ""
|
||||||
|
if dst.exists():
|
||||||
|
original_lines = original_text.splitlines()
|
||||||
|
start_positions = [
|
||||||
|
index
|
||||||
|
for index, line in enumerate(original_lines)
|
||||||
|
if line.strip() == _GITIGNORE_BLOCK_START
|
||||||
|
]
|
||||||
|
end_positions = [
|
||||||
|
index
|
||||||
|
for index, line in enumerate(original_lines)
|
||||||
|
if line.strip() == _GITIGNORE_BLOCK_END
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
len(start_positions) != len(end_positions)
|
||||||
|
or len(start_positions) > 1
|
||||||
|
or (
|
||||||
|
start_positions
|
||||||
|
and start_positions[0] >= end_positions[0]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(".gitignore has an invalid playbook .scratch block")
|
||||||
|
if start_positions:
|
||||||
|
updated_text = replace_marked_block(
|
||||||
|
original_text,
|
||||||
|
block_lines,
|
||||||
|
_GITIGNORE_BLOCK_START,
|
||||||
|
_GITIGNORE_BLOCK_END,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = original_text.rstrip("\n")
|
||||||
|
if content:
|
||||||
|
content += "\n\n"
|
||||||
|
updated_text = content + "\n".join(block_lines) + "\n"
|
||||||
|
else:
|
||||||
|
updated_text = "\n".join(block_lines) + "\n"
|
||||||
|
|
||||||
|
if dst.exists() and updated_text == original_text:
|
||||||
|
log("Unchanged: .gitignore (playbook .scratch block)")
|
||||||
|
return
|
||||||
|
backup_path(dst, no_backup)
|
||||||
|
dst.write_text(updated_text, encoding="utf-8", newline="\n")
|
||||||
|
log("Synced: .gitignore (playbook .scratch block)")
|
||||||
|
|
||||||
|
|
||||||
def sync_standards_action(config: dict, context: dict) -> int:
|
def sync_standards_action(config: dict, context: dict) -> int:
|
||||||
if "langs" not in config:
|
if "langs" not in config:
|
||||||
print("ERROR: langs is required for sync_standards", file=sys.stderr)
|
print("ERROR: langs is required for sync_standards", file=sys.stderr)
|
||||||
|
|||||||
@@ -123,9 +123,11 @@ setup-matt-pocock-skills
|
|||||||
-> to-spec
|
-> to-spec
|
||||||
-> to-tickets
|
-> to-tickets
|
||||||
-> main_loop.py enqueue
|
-> main_loop.py enqueue
|
||||||
|
-> 提交 planning baseline
|
||||||
-> main_loop.py claim
|
-> main_loop.py claim
|
||||||
-> 本地 ticket 执行协议 (tdd -> commit -> code-review -> finish)
|
-> 本地 ticket 执行协议 (tdd -> commit -> code-review -> finish)
|
||||||
-> main_loop.py integrate
|
-> main_loop.py integrate
|
||||||
|
-> 提交 final workflow state
|
||||||
```
|
```
|
||||||
|
|
||||||
- 每个仓库首次使用时运行 `setup-matt-pocock-skills`,本地开发选择 local markdown tracker
|
- 每个仓库首次使用时运行 `setup-matt-pocock-skills`,本地开发选择 local markdown tracker
|
||||||
@@ -139,6 +141,9 @@ setup-matt-pocock-skills
|
|||||||
- `to-spec` 不重新进行已经完成的需求采访
|
- `to-spec` 不重新进行已经完成的需求采访
|
||||||
- `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by`
|
- `to-tickets` 产出可独立验证的 tracer-bullet tickets,并显式声明 `Blocked by`
|
||||||
- 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue`
|
- 一次可以先生成多个 feature 的 spec/tickets,再按期望顺序逐个 `enqueue`
|
||||||
|
- 本批次全部 `enqueue` 成功后、任何 `claim` 之前,必须把对应的
|
||||||
|
`.scratch/<feature>/spec.md`、`.scratch/<feature>/issues/*.md` 和 `.scratch/queue.md`
|
||||||
|
提交为一个 planning baseline;`to-spec`、`to-tickets` 和 `enqueue` 本身不隐式提交
|
||||||
- `codebase-design` 是 seam、deep module 与依赖分类的词汇来源,供 `to-spec` 和 `tdd` 查阅,
|
- `codebase-design` 是 seam、deep module 与依赖分类的词汇来源,供 `to-spec` 和 `tdd` 查阅,
|
||||||
不作为独立会话运行
|
不作为独立会话运行
|
||||||
|
|
||||||
@@ -196,11 +201,6 @@ setup-matt-pocock-skills
|
|||||||
不得手工修改 ticket 的 `Status` 或 `main-loop:ticket-state` 区块;只通过主循环变更。主循环
|
不得手工修改 ticket 的 `Status` 或 `main-loop:ticket-state` 区块;只通过主循环变更。主循环
|
||||||
没有对应命令的状态组合按"卡死与恢复"处理,仍然不手工改。
|
没有对应命令的状态组合按"卡死与恢复"处理,仍然不手工改。
|
||||||
|
|
||||||
`.scratch/` 是否纳入版本控制由项目决定并写入 `AGENT_RULES.local.md`:纳入则 spec、ticket 和
|
|
||||||
证据进入历史,可审计、新 clone 能接手队列,代价是状态变更产生提交噪音;排除则历史干净,
|
|
||||||
代价是状态只存在于本机磁盘、集成后审计线索消失,并发只靠"共享同一文件系统"兜住。无论哪种
|
|
||||||
都不得把两份 `.scratch/` 当成同一队列。
|
|
||||||
|
|
||||||
## 稳定知识维护
|
## 稳定知识维护
|
||||||
|
|
||||||
只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和集成
|
只记录下一 session 仍需要的稳定知识;当前 feature、ticket、owner、heartbeat、验证和集成
|
||||||
@@ -444,6 +444,12 @@ review 后再调用。main 合并冲突时主循环会**自动**把 feature 标
|
|||||||
`_integration` worktree;不干净或路径异常的保留并以 `WARNING=` 行报告。已集成的 feature
|
`_integration` worktree;不干净或路径异常的保留并以 `WARNING=` 行报告。已集成的 feature
|
||||||
再次调用时幂等返回 `INTEGRATED=`。
|
再次调用时幂等返回 `INTEGRATED=`。
|
||||||
|
|
||||||
|
`integrate` 成功后必须在主干提交本 feature 的最终 workflow state。只暂存
|
||||||
|
`.scratch/<feature>/` 下的持久变更,以及确由本次集成改写时的 `.scratch/queue.md`;不得用
|
||||||
|
`git add .scratch` 把其他活动 feature 的并发状态带入。该状态提交必须位于 feature merge commit
|
||||||
|
之后,不得 amend 或 squash 进 merge commit;`.scratch/<feature>/.main-loop.json` 中记录的
|
||||||
|
`integration_commit` 必须继续指向主循环返回的 `MAIN_INTEGRATION_COMMIT`。
|
||||||
|
|
||||||
ready-to-integrate feature 因人工决策暂时不能集成时必须显式记录,否则主循环不会为后序
|
ready-to-integrate feature 因人工决策暂时不能集成时必须显式记录,否则主循环不会为后序
|
||||||
feature 继续分配开发工作:
|
feature 继续分配开发工作:
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ playbook/
|
|||||||
templates/
|
templates/
|
||||||
├── AGENTS.template.md # 项目主入口与导航模板
|
├── AGENTS.template.md # 项目主入口与导航模板
|
||||||
├── AGENT_RULES.template.md # Matt Pocock ticket-native 执行规则模板
|
├── AGENT_RULES.template.md # Matt Pocock ticket-native 执行规则模板
|
||||||
|
├── gitignore.template # .scratch 持久状态与本机运行时忽略规则
|
||||||
├── memory-bank/ # 稳定项目知识模板(3 个),不是机器状态源
|
├── memory-bank/ # 稳定项目知识模板(3 个),不是机器状态源
|
||||||
├── cpp/ # C++ 工具链模板
|
├── cpp/ # C++ 工具链模板
|
||||||
└── python/ # Python 工具链模板
|
└── python/ # Python 工具链模板
|
||||||
@@ -47,6 +48,7 @@ templates/
|
|||||||
- `AGENT_RULES.md`:`<!-- playbook:rules:start/end -->` 区块内的工程流程由 Playbook
|
- `AGENT_RULES.md`:`<!-- playbook:rules:start/end -->` 区块内的工程流程由 Playbook
|
||||||
维护,重新同步时刷新;项目自己的补充写在区块外,同步不会动它
|
维护,重新同步时刷新;项目自己的补充写在区块外,同步不会动它
|
||||||
- `AGENT_RULES.local.md`:项目私有规则,Playbook 不覆盖
|
- `AGENT_RULES.local.md`:项目私有规则,Playbook 不覆盖
|
||||||
|
- `.gitignore`:Playbook 维护专属标记区块,仅忽略 `.scratch` 下的本机运行时产物
|
||||||
- `memory-bank/`:稳定项目定位、技术上下文和当前系统模式,不承载机器状态
|
- `memory-bank/`:稳定项目定位、技术上下文和当前系统模式,不承载机器状态
|
||||||
|
|
||||||
Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*` 不会被删除。
|
Playbook 只处理框架提供的同名文件。项目新增的 `memory-bank/*` 不会被删除。
|
||||||
@@ -119,12 +121,14 @@ setup-matt-pocock-skills
|
|||||||
-> to-spec
|
-> to-spec
|
||||||
-> to-tickets
|
-> to-tickets
|
||||||
-> main_loop.py enqueue
|
-> main_loop.py enqueue
|
||||||
|
-> 提交 planning baseline(spec + tickets + queue)
|
||||||
-> main_loop.py claim
|
-> main_loop.py claim
|
||||||
-> 本地 ticket 执行协议(tdd)
|
-> 本地 ticket 执行协议(tdd)
|
||||||
-> commit
|
-> commit
|
||||||
-> code-review
|
-> code-review
|
||||||
-> main_loop.py finish
|
-> main_loop.py finish
|
||||||
-> main_loop.py integrate
|
-> main_loop.py integrate
|
||||||
|
-> 提交 final workflow state
|
||||||
```
|
```
|
||||||
|
|
||||||
产物和职责:
|
产物和职责:
|
||||||
@@ -136,6 +140,12 @@ setup-matt-pocock-skills
|
|||||||
- `docs/adr/`:长期架构决策
|
- `docs/adr/`:长期架构决策
|
||||||
- `docs/agents/*.md`:tracker 与领域文档配置
|
- `docs/agents/*.md`:tracker 与领域文档配置
|
||||||
|
|
||||||
|
本批次 feature 全部 `enqueue` 后,先把对应 spec、tickets 和 queue 提交为一个 planning
|
||||||
|
baseline,再开始任何 `claim`。生成和入队步骤本身不隐式提交。
|
||||||
|
|
||||||
|
feature 集成成功后,再在主干提交该 feature 的最终 `.scratch` 持久状态。此提交位于 feature
|
||||||
|
merge commit 之后,并与其他活动 feature 的未提交状态隔离。
|
||||||
|
|
||||||
明确串行时可用 in-place,不创建 worktree;计划多个 session 并发时,第一个 claim
|
明确串行时可用 in-place,不创建 worktree;计划多个 session 并发时,第一个 claim
|
||||||
就指定 worktree。并发 sessions 必须共享同一文件系统与 Git common directory;
|
就指定 worktree。并发 sessions 必须共享同一文件系统与 Git common directory;
|
||||||
`main_loop.py` 不支持跨机器、独立 clone 或远程 tracker adapter。Matt skills 可单独
|
`main_loop.py` 不支持跨机器、独立 clone 或远程 tracker adapter。Matt skills 可单独
|
||||||
@@ -175,6 +185,7 @@ project/
|
|||||||
├── AGENTS.md # 任一 sync_* 节启用时创建或按区块更新
|
├── AGENTS.md # 任一 sync_* 节启用时创建或按区块更新
|
||||||
├── AGENT_RULES.md # [sync_rules]
|
├── AGENT_RULES.md # [sync_rules]
|
||||||
├── AGENT_RULES.local.md # 首次成功写入规则时创建,后续由项目维护
|
├── AGENT_RULES.local.md # 首次成功写入规则时创建,后续由项目维护
|
||||||
|
├── .gitignore # [sync_rules] 更新 Playbook .scratch 标记区块
|
||||||
├── CLAUDE.md # 默认位置;也可使用 .claude/CLAUDE.md
|
├── CLAUDE.md # 默认位置;也可使用 .claude/CLAUDE.md
|
||||||
├── .agents/ # [sync_standards]
|
├── .agents/ # [sync_standards]
|
||||||
├── .gitattributes # [sync_standards] 按配置同步
|
├── .gitattributes # [sync_standards] 按配置同步
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Keep durable Playbook workflow state under .scratch version-controlled.
|
||||||
|
!/.scratch/
|
||||||
|
!/.scratch/**
|
||||||
|
|
||||||
|
# Ignore only machine-local main-loop runtime artifacts.
|
||||||
|
/.scratch/*.lock
|
||||||
|
/.scratch/worktrees/
|
||||||
|
/.scratch/**/*.tmp
|
||||||
@@ -108,6 +108,11 @@ def seed_custom_files(project_root: Path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
newline="\n",
|
newline="\n",
|
||||||
)
|
)
|
||||||
|
(project_root / ".gitignore").write_text(
|
||||||
|
"build/\n.scratch/\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlaybookDeploymentTests(unittest.TestCase):
|
class PlaybookDeploymentTests(unittest.TestCase):
|
||||||
@@ -254,6 +259,7 @@ project_root = "C:\workspace\project"
|
|||||||
"AGENT_RULES.md",
|
"AGENT_RULES.md",
|
||||||
"AGENT_RULES.local.md",
|
"AGENT_RULES.local.md",
|
||||||
"CLAUDE.md",
|
"CLAUDE.md",
|
||||||
|
".gitignore",
|
||||||
".gitattributes",
|
".gitattributes",
|
||||||
"memory-bank/project-brief.md",
|
"memory-bank/project-brief.md",
|
||||||
"memory-bank/system-patterns.md",
|
"memory-bank/system-patterns.md",
|
||||||
@@ -332,6 +338,22 @@ project_root = "C:\workspace\project"
|
|||||||
f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录",
|
f"`{playbook_root.as_posix()}/` 是 Playbook 模板/供应商目录",
|
||||||
rules_text,
|
rules_text,
|
||||||
)
|
)
|
||||||
|
gitignore_text = (project_root / ".gitignore").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("build/", gitignore_text)
|
||||||
|
self.assertEqual(
|
||||||
|
gitignore_text.count("# BEGIN playbook .scratch runtime"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
gitignore_text.count("# END playbook .scratch runtime"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertIn("!/.scratch/**", gitignore_text)
|
||||||
|
self.assertIn("/.scratch/*.lock", gitignore_text)
|
||||||
|
self.assertIn("/.scratch/worktrees/", gitignore_text)
|
||||||
|
self.assertIn("/.scratch/**/*.tmp", gitignore_text)
|
||||||
|
|
||||||
if install_mode == "snapshot":
|
if install_mode == "snapshot":
|
||||||
snapshot_root = project_root / playbook_root
|
snapshot_root = project_root / playbook_root
|
||||||
@@ -345,6 +367,9 @@ project_root = "C:\workspace\project"
|
|||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(snapshot_root / "playbook.example.toml").is_file()
|
(snapshot_root / "playbook.example.toml").is_file()
|
||||||
)
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
(snapshot_root / "templates/gitignore.template").is_file()
|
||||||
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
(snapshot_root / "playbook.toml.example").exists()
|
(snapshot_root / "playbook.toml.example").exists()
|
||||||
)
|
)
|
||||||
@@ -355,6 +380,57 @@ project_root = "C:\workspace\project"
|
|||||||
else:
|
else:
|
||||||
self.assertFalse((source_root / "SOURCE.md").exists())
|
self.assertFalse((source_root / "SOURCE.md").exists())
|
||||||
|
|
||||||
|
def test_sync_rules_gitignore_block_tracks_durable_scratch_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
project_root = Path(tmp_dir) / "project"
|
||||||
|
project_root.mkdir()
|
||||||
|
seed_custom_files(project_root)
|
||||||
|
initialized = subprocess.run(
|
||||||
|
["git", "init", "-q"],
|
||||||
|
cwd=project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(initialized.returncode, 0, msg=initialized.stderr)
|
||||||
|
|
||||||
|
playbook_root = MODE_ROOTS["snapshot"]
|
||||||
|
config = write_config(project_root, "snapshot", playbook_root)
|
||||||
|
result = run_playbook(SCRIPT, config, project_root)
|
||||||
|
self.assertEqual(result.returncode, 0, msg=f"{result.stdout}{result.stderr}")
|
||||||
|
|
||||||
|
paths = {
|
||||||
|
"queue": project_root / ".scratch/queue.md",
|
||||||
|
"spec": project_root / ".scratch/alpha/spec.md",
|
||||||
|
"evidence": project_root / ".scratch/alpha/evidence/check.json",
|
||||||
|
"lock": project_root / ".scratch/.main-loop.lock",
|
||||||
|
"other_lock": project_root / ".scratch/worker.lock",
|
||||||
|
"worktree": project_root / ".scratch/worktrees/alpha/file.txt",
|
||||||
|
"temp": project_root / ".scratch/alpha/.spec.md.deadbeef.tmp",
|
||||||
|
}
|
||||||
|
for path in paths.values():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("test\n", encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
def is_ignored(path: Path) -> bool:
|
||||||
|
checked = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"check-ignore",
|
||||||
|
"-q",
|
||||||
|
"--",
|
||||||
|
path.relative_to(project_root).as_posix(),
|
||||||
|
],
|
||||||
|
cwd=project_root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return checked.returncode == 0
|
||||||
|
|
||||||
|
for name in ("queue", "spec", "evidence"):
|
||||||
|
self.assertFalse(is_ignored(paths[name]), msg=f"{name} must be tracked")
|
||||||
|
for name in ("lock", "other_lock", "worktree", "temp"):
|
||||||
|
self.assertTrue(is_ignored(paths[name]), msg=f"{name} must be ignored")
|
||||||
|
|
||||||
def test_sync_without_standards_keeps_language_rules(self):
|
def test_sync_without_standards_keeps_language_rules(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
project_root = Path(tmp_dir) / "project"
|
project_root = Path(tmp_dir) / "project"
|
||||||
|
|||||||
@@ -277,17 +277,20 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
for legacy in LEGACY_FLOW_TERMS:
|
for legacy in LEGACY_FLOW_TERMS:
|
||||||
self.assertNotIn(legacy, rules)
|
self.assertNotIn(legacy, rules)
|
||||||
|
|
||||||
def test_agent_rules_states_whether_scratch_is_version_controlled(self):
|
def test_gitignore_template_tracks_scratch_and_ignores_only_runtime(self):
|
||||||
rules = rules_text()
|
template = (TEMPLATES / "gitignore.template").read_text(encoding="utf-8")
|
||||||
responsibilities = section(rules, "## 文档职责", "## 稳定知识维护")
|
|
||||||
|
|
||||||
self.assertIn("AGENT_RULES.local.md", responsibilities)
|
for durable_rule in ("!/.scratch/", "!/.scratch/**"):
|
||||||
self.assertIn(
|
self.assertIn(durable_rule, template)
|
||||||
"纳入版本控制",
|
for runtime_rule in (
|
||||||
responsibilities,
|
"/.scratch/*.lock",
|
||||||
msg="the review contract reads spec sources out of .scratch/, so "
|
"/.scratch/worktrees/",
|
||||||
"whether it is tracked has to be an explicit project decision",
|
"/.scratch/**/*.tmp",
|
||||||
)
|
):
|
||||||
|
self.assertIn(runtime_rule, template)
|
||||||
|
|
||||||
|
rules = rules_text()
|
||||||
|
self.assertNotIn("是否纳入版本控制由项目决定", rules)
|
||||||
|
|
||||||
def test_agent_rules_routes_each_current_matt_on_ramp_to_its_destination(self):
|
def test_agent_rules_routes_each_current_matt_on_ramp_to_its_destination(self):
|
||||||
rules = rules_text()
|
rules = rules_text()
|
||||||
@@ -315,6 +318,47 @@ class TemplateContractsTests(unittest.TestCase):
|
|||||||
"the skill's own phase order",
|
"the skill's own phase order",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_agent_rules_commits_planning_baseline_before_claim(self):
|
||||||
|
rules = rules_text()
|
||||||
|
main_chain = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
||||||
|
|
||||||
|
ordered_steps = (
|
||||||
|
"-> to-spec",
|
||||||
|
"-> to-tickets",
|
||||||
|
"-> main_loop.py enqueue",
|
||||||
|
"-> 提交 planning baseline",
|
||||||
|
"-> main_loop.py claim",
|
||||||
|
)
|
||||||
|
positions = [main_chain.index(step) for step in ordered_steps]
|
||||||
|
self.assertEqual(positions, sorted(positions))
|
||||||
|
for durable_input in (
|
||||||
|
"`.scratch/<feature>/spec.md`",
|
||||||
|
"`.scratch/<feature>/issues/*.md`",
|
||||||
|
"`.scratch/queue.md`",
|
||||||
|
):
|
||||||
|
self.assertIn(durable_input, main_chain)
|
||||||
|
self.assertIn("任何 `claim` 之前", main_chain)
|
||||||
|
self.assertIn("不隐式提交", main_chain)
|
||||||
|
|
||||||
|
def test_agent_rules_commits_final_workflow_state_after_integration(self):
|
||||||
|
rules = rules_text()
|
||||||
|
main_chain = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
||||||
|
integration = section(rules, "### Feature 顺序集成", "## Git 与证据门禁")
|
||||||
|
|
||||||
|
self.assertLess(
|
||||||
|
main_chain.index("-> main_loop.py integrate"),
|
||||||
|
main_chain.index("-> 提交 final workflow state"),
|
||||||
|
)
|
||||||
|
for durable_path in (
|
||||||
|
"`.scratch/<feature>/`",
|
||||||
|
"`.scratch/queue.md`",
|
||||||
|
"`.scratch/<feature>/.main-loop.json`",
|
||||||
|
):
|
||||||
|
self.assertIn(durable_path, integration)
|
||||||
|
self.assertIn("不得用\n`git add .scratch`", integration)
|
||||||
|
self.assertIn("不得 amend 或 squash", integration)
|
||||||
|
self.assertIn("`MAIN_INTEGRATION_COMMIT`", integration)
|
||||||
|
|
||||||
def test_agent_rules_defines_a_local_ticket_execution_adapter(self):
|
def test_agent_rules_defines_a_local_ticket_execution_adapter(self):
|
||||||
rules = rules_text()
|
rules = rules_text()
|
||||||
main_flow = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
main_flow = section(rules, "## 正式工程主链", "## On-ramps 与 detours")
|
||||||
|
|||||||
Reference in New Issue
Block a user