@michaelxwb/code-flow 0.6.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -110
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +268 -0
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +21 -9
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -264
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -67
- package/src/adapters/claude/commands/cf-stats.md +0 -86
- package/src/adapters/claude/commands/cf-sync.md +0 -25
- package/src/adapters/claude/commands/cf-task/align.md +0 -309
- package/src/adapters/claude/commands/cf-task/archive.md +0 -161
- package/src/adapters/claude/commands/cf-task/block.md +0 -36
- package/src/adapters/claude/commands/cf-task/graph.md +0 -80
- package/src/adapters/claude/commands/cf-task/note.md +0 -236
- package/src/adapters/claude/commands/cf-task/plan.md +0 -363
- package/src/adapters/claude/commands/cf-task/prd.md +0 -162
- package/src/adapters/claude/commands/cf-task/start.md +0 -304
- package/src/adapters/claude/commands/cf-task/status.md +0 -59
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/claude/commands/cf-validate.md +0 -75
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -50
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -264
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -67
- package/src/adapters/costrict/commands/cf-stats.md +0 -86
- package/src/adapters/costrict/commands/cf-sync.md +0 -25
- package/src/adapters/costrict/commands/cf-task/align.md +0 -309
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
- package/src/adapters/costrict/commands/cf-task/block.md +0 -36
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
- package/src/adapters/costrict/commands/cf-task/note.md +0 -236
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
- package/src/adapters/costrict/commands/cf-task/start.md +0 -304
- package/src/adapters/costrict/commands/cf-task/status.md +0 -59
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/costrict/commands/cf-validate.md +0 -75
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -59
- package/src/adapters/opencode/commands/cf-stats.md +0 -90
- package/src/adapters/opencode/commands/cf-sync.md +0 -29
- package/src/adapters/opencode/commands/cf-task/align.md +0 -314
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
- package/src/adapters/opencode/commands/cf-task/block.md +0 -40
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
- package/src/adapters/opencode/commands/cf-task/note.md +0 -240
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
- package/src/adapters/opencode/commands/cf-task/start.md +0 -309
- package/src/adapters/opencode/commands/cf-task/status.md +0 -63
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
- package/src/adapters/opencode/commands/cf-validate.md +0 -79
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/cli.js +0 -1023
- package/src/core/code-flow/config.yml +0 -158
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_checks.py +0 -336
- package/src/core/code-flow/scripts/cf_core.py +0 -787
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_feedback.py +0 -56
- package/src/core/code-flow/scripts/cf_log.py +0 -154
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
- package/src/core/code-flow/scripts/cf_scan.py +0 -295
- package/src/core/code-flow/scripts/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
- package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
- package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
- package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
- package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
- package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
- package/src/core/code-flow/scripts/cf_stats.py +0 -506
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
- package/src/core/code-flow/scripts/cf_sync.py +0 -140
- package/src/core/code-flow/scripts/cf_task_index.py +0 -148
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
- package/src/core/code-flow/scripts/cf_validation.py +0 -81
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -53
- package/src/migrate/spec-workflow.js +0 -238
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
version: 1
|
|
2
|
-
|
|
3
|
-
spec_workflow:
|
|
4
|
-
schema_version: 1
|
|
5
|
-
enforcement: required # required=全门禁 | warn=门禁仅记录不阻断 | inject=只注入不门禁
|
|
6
|
-
drift_check: true
|
|
7
|
-
scope_expansion_check: true
|
|
8
|
-
metrics: true
|
|
9
|
-
catalog:
|
|
10
|
-
dedup_window: 5
|
|
11
|
-
|
|
12
|
-
budget:
|
|
13
|
-
total: 2500
|
|
14
|
-
l0_max: 800
|
|
15
|
-
l1_max: 1700
|
|
16
|
-
map_max: 400
|
|
17
|
-
# catalog 模式下 spec 目录的 token 上限(超出按 Tier1 优先截断并告警)
|
|
18
|
-
catalog_max: 200
|
|
19
|
-
|
|
20
|
-
# 质量闭环(v0.5):合规反馈 + 学习采集。
|
|
21
|
-
# enabled 仅 literal true 启用(缺失即关闭,升级用户行为不变);
|
|
22
|
-
# 子开关缺省跟随 enabled,仅 literal false 单独关闭。
|
|
23
|
-
# 数据仅存本地 .code-flow/(.session-log.jsonl / .check-state.json)。
|
|
24
|
-
quality_loop:
|
|
25
|
-
enabled: true
|
|
26
|
-
post_check: true # PostToolUse 违规反馈(只提示不阻断)
|
|
27
|
-
stop_check: true # Stop 收尾按 validation.yml 校验(heavy 项跳过)
|
|
28
|
-
finish_check: true # 任务 finish 时全量执行 validation.yml(含 heavy,每任务一次)
|
|
29
|
-
correction_capture: true # 纠正句式采集(喂 cf-learn 候选)
|
|
30
|
-
# 注入时对 spec 内容做保守无损压缩(去行尾空白、折叠多空行、剥 HTML 注释、去重 bullet)
|
|
31
|
-
# 缺省视为 true;仅显式 false 关闭
|
|
32
|
-
compress: true
|
|
33
|
-
compress_reminder: true # 会话过长每 N 轮提醒 /compact(per-session 计数,缺省 true)
|
|
34
|
-
code_extensions:
|
|
35
|
-
- ".py"
|
|
36
|
-
- ".ts"
|
|
37
|
-
- ".tsx"
|
|
38
|
-
- ".js"
|
|
39
|
-
- ".jsx"
|
|
40
|
-
- ".go"
|
|
41
|
-
- ".java"
|
|
42
|
-
- ".rs"
|
|
43
|
-
- ".rb"
|
|
44
|
-
- ".vue"
|
|
45
|
-
- ".svelte"
|
|
46
|
-
skip_extensions:
|
|
47
|
-
- ".md"
|
|
48
|
-
- ".txt"
|
|
49
|
-
- ".json"
|
|
50
|
-
- ".yml"
|
|
51
|
-
- ".yaml"
|
|
52
|
-
- ".toml"
|
|
53
|
-
- ".lock"
|
|
54
|
-
- ".csv"
|
|
55
|
-
- ".xml"
|
|
56
|
-
- ".svg"
|
|
57
|
-
- ".png"
|
|
58
|
-
- ".jpg"
|
|
59
|
-
- ".jpeg"
|
|
60
|
-
- ".gif"
|
|
61
|
-
- ".ico"
|
|
62
|
-
- ".pdf"
|
|
63
|
-
- ".zip"
|
|
64
|
-
- ".gz"
|
|
65
|
-
- ".tar"
|
|
66
|
-
skip_paths:
|
|
67
|
-
- "docs/**"
|
|
68
|
-
- "*.config.*"
|
|
69
|
-
- ".code-flow/**"
|
|
70
|
-
- ".claude/**"
|
|
71
|
-
- ".codex/**"
|
|
72
|
-
- ".costrict/**"
|
|
73
|
-
- ".opencode/**"
|
|
74
|
-
- "node_modules/**"
|
|
75
|
-
- "dist/**"
|
|
76
|
-
- "build/**"
|
|
77
|
-
- "out/**"
|
|
78
|
-
- "coverage/**"
|
|
79
|
-
- ".next/**"
|
|
80
|
-
- ".cache/**"
|
|
81
|
-
- ".venv/**"
|
|
82
|
-
- "venv/**"
|
|
83
|
-
- "__pycache__/**"
|
|
84
|
-
- ".git/**"
|
|
85
|
-
|
|
86
|
-
path_mapping:
|
|
87
|
-
shared:
|
|
88
|
-
# 共享模板供 cf-task:align / cf-task:prd 命令显式 Read 使用,
|
|
89
|
-
# 不是约束规范,禁止通配符自动注入。
|
|
90
|
-
# 仅 _map.md 保持 "*" 作为 Tier 0 导航。
|
|
91
|
-
specs:
|
|
92
|
-
- path: "shared/_map.md"
|
|
93
|
-
tags: ["*"]
|
|
94
|
-
tier: 0
|
|
95
|
-
- path: "shared/prd-template.md"
|
|
96
|
-
tags: [] # 永不自动注入,仅 cf-task:prd 命令显式 Read
|
|
97
|
-
tier: 1
|
|
98
|
-
- path: "shared/design/design-lite.md"
|
|
99
|
-
tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
|
|
100
|
-
tier: 1
|
|
101
|
-
- path: "shared/design/design-full.md"
|
|
102
|
-
tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read
|
|
103
|
-
tier: 1
|
|
104
|
-
- path: "shared/design/design-frontend.md"
|
|
105
|
-
tags: [] # 永不自动注入,仅 cf-task:align 命令显式 Read(前端设计模板)
|
|
106
|
-
tier: 1
|
|
107
|
-
frontend:
|
|
108
|
-
patterns:
|
|
109
|
-
- "src/components/**"
|
|
110
|
-
- "src/pages/**"
|
|
111
|
-
- "src/hooks/**"
|
|
112
|
-
- "src/composables/**"
|
|
113
|
-
- "src/services/**"
|
|
114
|
-
- "src/styles/**"
|
|
115
|
-
- "**/*.tsx"
|
|
116
|
-
- "**/*.jsx"
|
|
117
|
-
- "**/*.css"
|
|
118
|
-
- "**/*.scss"
|
|
119
|
-
specs:
|
|
120
|
-
- path: "frontend/_map.md"
|
|
121
|
-
tags: ["*"]
|
|
122
|
-
tier: 0
|
|
123
|
-
- path: "frontend/directory-structure.md"
|
|
124
|
-
tags: ["directory", "structure", "folder", "route", "page", "layout"]
|
|
125
|
-
tier: 1
|
|
126
|
-
- path: "frontend/quality-standards.md"
|
|
127
|
-
tags: ["quality", "type", "lint", "error", "test", "state", "strict"]
|
|
128
|
-
tier: 1
|
|
129
|
-
- path: "frontend/component-specs.md"
|
|
130
|
-
tags: ["component", "prop", "hook", "render", "ui", "style"]
|
|
131
|
-
tier: 1
|
|
132
|
-
backend:
|
|
133
|
-
patterns:
|
|
134
|
-
- "services/**"
|
|
135
|
-
- "api/**"
|
|
136
|
-
- "models/**"
|
|
137
|
-
- "*.py"
|
|
138
|
-
- "**/*.py"
|
|
139
|
-
- "**/*.go"
|
|
140
|
-
specs:
|
|
141
|
-
- path: "backend/_map.md"
|
|
142
|
-
tags: ["*"]
|
|
143
|
-
tier: 0
|
|
144
|
-
- path: "backend/directory-structure.md"
|
|
145
|
-
tags: ["directory", "structure", "folder", "module", "layout"]
|
|
146
|
-
tier: 1
|
|
147
|
-
- path: "backend/logging.md"
|
|
148
|
-
tags: ["log", "logging", "debug", "trace", "monitor", "observe"]
|
|
149
|
-
tier: 1
|
|
150
|
-
- path: "backend/database.md"
|
|
151
|
-
tags: ["database", "db", "sql", "orm", "model", "migration", "query", "table", "schema"]
|
|
152
|
-
tier: 1
|
|
153
|
-
- path: "backend/platform-rules.md"
|
|
154
|
-
tags: ["api", "deploy", "config", "version", "compatibility", "release", "flag"]
|
|
155
|
-
tier: 1
|
|
156
|
-
- path: "backend/code-quality-performance.md"
|
|
157
|
-
tags: ["quality", "performance", "error", "exception", "test", "timeout", "retry", "cache"]
|
|
158
|
-
tier: 1
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""Append execution history without erasing the user's acceptance contract."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Mapping, Sequence
|
|
9
|
-
from uuid import uuid4
|
|
10
|
-
|
|
11
|
-
STATES = frozenset(("planned", "pending", "tbd", "verified", "unverified", "failed", "incomplete", "not_configured", "e2e_deferred", "manual_pending"))
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def scenario_line(line: str, scenario_id: str) -> bool:
|
|
15
|
-
return bool(re.match(rf"^\s*(?:\|\s*|[-*]\s+){re.escape(scenario_id)}(?=\s*[:|])", line))
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def line_status(line: str, scenario_id: str) -> str:
|
|
19
|
-
if not scenario_line(line, scenario_id):
|
|
20
|
-
return ""
|
|
21
|
-
if line.lstrip().startswith("|"):
|
|
22
|
-
cells = [cell.strip().lower() for cell in line.strip().strip("|").split("|")]
|
|
23
|
-
return next((cell for cell in reversed(cells) if cell in STATES), "")
|
|
24
|
-
match = re.match(r"^\s*[-*]\s+[^:]+:\s*([a-z0-9_]+)\b", line, re.I)
|
|
25
|
-
return match.group(1).lower() if match and match.group(1).lower() in STATES else ""
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _status_line(line: str, scenario_id: str, status: str) -> str:
|
|
29
|
-
if not scenario_line(line, scenario_id):
|
|
30
|
-
return line
|
|
31
|
-
if line.lstrip().startswith("|"):
|
|
32
|
-
cells = line.split("|")
|
|
33
|
-
for index in range(len(cells) - 1, 0, -1):
|
|
34
|
-
if cells[index].strip().lower() in STATES:
|
|
35
|
-
cells[index] = f" {status} "
|
|
36
|
-
return "|".join(cells)
|
|
37
|
-
return line.rstrip().rstrip("|") + f"| {status} |"
|
|
38
|
-
prefix = r"(^\s*[-*]\s+[^:]+:\s*)"
|
|
39
|
-
if line_status(line, scenario_id):
|
|
40
|
-
return re.sub(prefix + r"(?:" + "|".join(sorted(STATES)) + r")\b", rf"\g<1>{status}", line, count=1, flags=re.I)
|
|
41
|
-
return re.sub(prefix, rf"\g<1>{status} — ", line, count=1)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _patch_section(section: str, scenario_id: str, status: str, record: str) -> str:
|
|
45
|
-
contract = re.search(r"(?ms)^### Acceptance Contract[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
46
|
-
if contract:
|
|
47
|
-
body = "\n".join(_status_line(line, scenario_id, status) for line in contract.group(1).splitlines()) + "\n"
|
|
48
|
-
section = section[:contract.start(1)] + body + section[contract.end(1):]
|
|
49
|
-
evidence = re.search(r"(?ms)^### Acceptance Evidence[^\n]*\n(.*?)(?=^### |^## |\Z)", section)
|
|
50
|
-
if evidence:
|
|
51
|
-
body = evidence.group(1)
|
|
52
|
-
if record not in body.splitlines():
|
|
53
|
-
body = body.rstrip() + "\n" + record + "\n\n"
|
|
54
|
-
section = section[:evidence.start(1)] + body + section[evidence.end(1):]
|
|
55
|
-
else:
|
|
56
|
-
section = section.rstrip() + "\n\n### Acceptance Evidence\n" + record + "\n"
|
|
57
|
-
return section
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def sync_task_evidence(task: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "", status: str = "verified") -> None:
|
|
61
|
-
text = task.read_text(encoding="utf-8")
|
|
62
|
-
task_pattern = re.escape(owner) if owner else r"TASK-\d+"
|
|
63
|
-
match = re.search(rf"(?ms)^##\s+{task_pattern}:.*?(?=^## |\Z)", text)
|
|
64
|
-
if match is None:
|
|
65
|
-
raise ValueError(f"acceptance owner missing: {owner}")
|
|
66
|
-
record = f"- {scenario_id}: {status} — {evidence} (confirmed_by: {confirmed_by})"
|
|
67
|
-
section = _patch_section(match.group(0), scenario_id, status, record)
|
|
68
|
-
text = text[:match.start()] + section + text[match.end():]
|
|
69
|
-
coverage = re.search(r"(?ms)^## Acceptance Coverage[^\n]*\n(.*?)(?=^## |\Z)", text)
|
|
70
|
-
if coverage:
|
|
71
|
-
body = "\n".join(_status_line(line, scenario_id, status) for line in coverage.group(1).splitlines()) + "\n"
|
|
72
|
-
text = text[:coverage.start(1)] + body + text[coverage.end(1):]
|
|
73
|
-
from cf_spec_context import _atomic_text
|
|
74
|
-
_atomic_text(task, text)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def persist_results(path: Path, data: dict[str, object], results: Sequence[Mapping[str, object]]) -> None:
|
|
78
|
-
rows = {row["id"]: row for row in data["scenarios"]}
|
|
79
|
-
run_id, executed_at = uuid4().hex, datetime.now(timezone.utc).isoformat()
|
|
80
|
-
updates = []
|
|
81
|
-
for result in results:
|
|
82
|
-
state = str(result["status"])
|
|
83
|
-
if state in ("not_included", "manual_pending"):
|
|
84
|
-
continue
|
|
85
|
-
row = rows[result["id"]]
|
|
86
|
-
row["revision"] = int(row.get("revision", 0)) + 1
|
|
87
|
-
status = "verified" if state == "passed" else state
|
|
88
|
-
row["status"] = status
|
|
89
|
-
record = {**result, "revision": row["revision"], "run_id": run_id, "executed_at": executed_at}
|
|
90
|
-
row["evidence"] = record
|
|
91
|
-
row.setdefault("runs", []).append(record)
|
|
92
|
-
updates.append((row, record, status))
|
|
93
|
-
from cf_spec_context import _atomic_text
|
|
94
|
-
_atomic_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
95
|
-
task_name = data.get("task_file")
|
|
96
|
-
if isinstance(task_name, str) and task_name:
|
|
97
|
-
task = Path(task_name) if Path(task_name).is_absolute() else path.parent / task_name
|
|
98
|
-
for row, record, status in updates:
|
|
99
|
-
detail = f"automated command {record['status']}; run_id={run_id}"
|
|
100
|
-
sync_task_evidence(task, str(row["id"]), "runner", detail, str(row.get("owner", "")), status)
|
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Lock and validate plan Acceptance Coverage entries."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import hashlib
|
|
8
|
-
import json
|
|
9
|
-
import shlex
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
import re
|
|
12
|
-
import sys
|
|
13
|
-
from typing import IO, Callable, Mapping, Optional, Sequence
|
|
14
|
-
|
|
15
|
-
from cf_acceptance_schema import load_manifest, validate_scenarios
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
_ROW_RE = re.compile(r"^\|\s*([SEB]-\d+)\s*\|(.+?)\|$")
|
|
19
|
-
|
|
20
|
-
_MANIFEST_SCHEMA = 2
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _kind(level: str) -> str:
|
|
24
|
-
"""Map test level to execution kind.
|
|
25
|
-
|
|
26
|
-
- functional: auto-executed in Done Gate (unit, integration)
|
|
27
|
-
- e2e: deferred until explicit --verify-e2e (needs external dependencies)
|
|
28
|
-
- manual: user confirmation required, never auto-executed
|
|
29
|
-
|
|
30
|
-
层级单元格允许带注解(如 `E2E(Playwright)`、`e2e/chrome`、`manual(chrome)`),
|
|
31
|
-
取分隔符前的词元判定;避免带注解的 E2E 被误判为 functional 而每任务执行。
|
|
32
|
-
"""
|
|
33
|
-
head = level.strip().lower()
|
|
34
|
-
for separator in ("(", "(", "[", "/", ",", ",", " ", "—", "-"):
|
|
35
|
-
head = head.split(separator, 1)[0]
|
|
36
|
-
head = head.strip()
|
|
37
|
-
if head in ("manual", "手工", "手动"):
|
|
38
|
-
return "manual"
|
|
39
|
-
if head in ("e2e", "端到端", "end"):
|
|
40
|
-
return "e2e"
|
|
41
|
-
return "functional"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _parse_command(cell: str, scenario_id: str) -> Optional[list[str]]:
|
|
45
|
-
"""Parse the optional 命令 column: argv JSON array preferred, else shell words.
|
|
46
|
-
|
|
47
|
-
Empty / `-` / `planned` → None (not yet registered, not executable).
|
|
48
|
-
"""
|
|
49
|
-
text = (cell or "").strip()
|
|
50
|
-
if not text or text in {"-", "planned", "pending", "TBD", "tbd"}:
|
|
51
|
-
return None
|
|
52
|
-
if text.startswith("["):
|
|
53
|
-
try:
|
|
54
|
-
value = json.loads(text)
|
|
55
|
-
except json.JSONDecodeError as exc:
|
|
56
|
-
raise ValueError(f"{scenario_id} 命令列不是合法 argv JSON: {exc}") from exc
|
|
57
|
-
if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
|
|
58
|
-
raise ValueError(f"{scenario_id} 命令列必须是 argv 字符串数组")
|
|
59
|
-
return list(value)
|
|
60
|
-
return shlex.split(text)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _parse_float(cell: str, default: float) -> float:
|
|
64
|
-
try:
|
|
65
|
-
return float((cell or "").strip() or default)
|
|
66
|
-
except (TypeError, ValueError):
|
|
67
|
-
return default
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _manifest_hash(rows: list[dict[str, object]]) -> str:
|
|
71
|
-
immutable = [
|
|
72
|
-
{key: item[key] for key in ("id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on")}
|
|
73
|
-
for item in rows
|
|
74
|
-
]
|
|
75
|
-
payload = json.dumps(immutable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
76
|
-
return hashlib.sha256(payload).hexdigest()
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _legacy_kind(level: str) -> str:
|
|
80
|
-
"""v1 精确匹配:仅用于识别旧版锁定的 manifest,不做新语义判定。"""
|
|
81
|
-
normalized = level.strip().lower()
|
|
82
|
-
if normalized == "manual":
|
|
83
|
-
return "manual"
|
|
84
|
-
if normalized == "e2e":
|
|
85
|
-
return "e2e"
|
|
86
|
-
return "functional"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _extract_rows(task_file: str, kind_fn: Callable[[str], str]) -> dict[str, object]:
|
|
90
|
-
path = Path(task_file)
|
|
91
|
-
text = path.read_text(encoding="utf-8")
|
|
92
|
-
rows: list[dict[str, object]] = []
|
|
93
|
-
in_table = False
|
|
94
|
-
seen: set[str] = set()
|
|
95
|
-
for line in text.splitlines():
|
|
96
|
-
if line.startswith("## Acceptance Coverage"):
|
|
97
|
-
in_table = True
|
|
98
|
-
continue
|
|
99
|
-
if in_table and line.startswith("## "):
|
|
100
|
-
break
|
|
101
|
-
if not in_table:
|
|
102
|
-
continue
|
|
103
|
-
match = _ROW_RE.match(line)
|
|
104
|
-
if not match or set(match.group(2).replace("|", "").strip()) <= {"-", " "}:
|
|
105
|
-
continue
|
|
106
|
-
fields = [item.strip() for item in match.group(2).split("|")]
|
|
107
|
-
if match.group(1) in seen:
|
|
108
|
-
raise ValueError(f"duplicate scenario id: {match.group(1)}")
|
|
109
|
-
if len(fields) < 5:
|
|
110
|
-
raise ValueError(f"Acceptance Coverage missing columns: {match.group(1)}")
|
|
111
|
-
seen.add(match.group(1))
|
|
112
|
-
depends = [item.strip() for item in (fields[8] if len(fields) > 8 else "").split(",") if item.strip()]
|
|
113
|
-
rows.append({
|
|
114
|
-
"id": match.group(1),
|
|
115
|
-
"source": fields[0],
|
|
116
|
-
"level": fields[1],
|
|
117
|
-
"kind": kind_fn(fields[1]),
|
|
118
|
-
"boundary": fields[2],
|
|
119
|
-
"owner": fields[3],
|
|
120
|
-
"status": fields[4],
|
|
121
|
-
"command": _parse_command(fields[5], match.group(1)) if len(fields) > 5 else None,
|
|
122
|
-
"cwd": (fields[6] if len(fields) > 6 else "").strip() or ".",
|
|
123
|
-
"timeout": _parse_float(fields[7], 60.0) if len(fields) > 7 else 60.0,
|
|
124
|
-
"depends_on": depends,
|
|
125
|
-
})
|
|
126
|
-
if not rows:
|
|
127
|
-
raise ValueError("Acceptance Coverage 缺失或为空")
|
|
128
|
-
validate_scenarios(rows)
|
|
129
|
-
return {"schema": _MANIFEST_SCHEMA, "task_file": str(path), "task_sha256": _manifest_hash(rows), "scenarios": rows}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def extract_manifest(task_file: str, kind_fn: Callable[[str], str] = _kind) -> dict[str, object]:
|
|
133
|
-
return _extract_rows(task_file, kind_fn)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def write_manifest(task_file: str, output: str) -> dict[str, object]:
|
|
137
|
-
manifest = extract_manifest(task_file)
|
|
138
|
-
target = Path(output)
|
|
139
|
-
try:
|
|
140
|
-
manifest["task_file"] = str(Path(task_file).resolve().relative_to(target.parent.resolve()))
|
|
141
|
-
except ValueError:
|
|
142
|
-
manifest["task_file"] = str(Path(task_file).resolve())
|
|
143
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
-
target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
145
|
-
return manifest
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
_IMMUTABLE_SCENARIO_FIELDS = (
|
|
149
|
-
"id", "source", "level", "kind", "boundary", "owner", "command", "cwd", "timeout", "depends_on",
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _heal_legacy_kinds(manifest_file: Path, manifest: dict[str, object], expected: dict[str, object]) -> None:
|
|
154
|
-
"""旧版 `_kind` 精确匹配把带注解的 E2E 锁成 functional;任务内容未变时
|
|
155
|
-
原地升级 kind 并重锁哈希,保留 execution evidence 等运行态字段。"""
|
|
156
|
-
by_id: dict[str, dict[str, object]] = {}
|
|
157
|
-
scenarios = expected.get("scenarios")
|
|
158
|
-
if isinstance(scenarios, list):
|
|
159
|
-
for item in scenarios:
|
|
160
|
-
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
161
|
-
by_id[item["id"]] = item
|
|
162
|
-
current = manifest.get("scenarios")
|
|
163
|
-
if isinstance(current, list):
|
|
164
|
-
for item in current:
|
|
165
|
-
if not isinstance(item, dict):
|
|
166
|
-
continue
|
|
167
|
-
target = by_id.get(item.get("id")) if isinstance(item.get("id"), str) else None
|
|
168
|
-
if target is not None:
|
|
169
|
-
item["kind"] = target.get("kind")
|
|
170
|
-
manifest["task_sha256"] = expected["task_sha256"]
|
|
171
|
-
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def validate_manifest(task_file: str, manifest_file: str) -> tuple[bool, str]:
|
|
175
|
-
try:
|
|
176
|
-
manifest = load_manifest(Path(manifest_file))
|
|
177
|
-
expected = extract_manifest(task_file)
|
|
178
|
-
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
179
|
-
return False, f"acceptance_manifest_invalid: {exc}"
|
|
180
|
-
if manifest.get("schema") != _MANIFEST_SCHEMA:
|
|
181
|
-
return False, "acceptance_manifest_drift"
|
|
182
|
-
if manifest.get("task_sha256") != expected["task_sha256"]:
|
|
183
|
-
legacy = extract_manifest(task_file, _legacy_kind)
|
|
184
|
-
if manifest.get("task_sha256") != legacy["task_sha256"]:
|
|
185
|
-
return False, "acceptance_manifest_drift"
|
|
186
|
-
_heal_legacy_kinds(Path(manifest_file), manifest, expected)
|
|
187
|
-
stored_task = manifest.get("task_file", "")
|
|
188
|
-
if not stored_task or (Path(manifest_file).parent / stored_task).resolve() != Path(task_file).resolve():
|
|
189
|
-
return False, "acceptance_manifest_task_mismatch"
|
|
190
|
-
if not isinstance(manifest.get("scenarios"), list):
|
|
191
|
-
return False, "acceptance_manifest_scenarios_changed"
|
|
192
|
-
stored = {
|
|
193
|
-
item.get("id"): item
|
|
194
|
-
for item in manifest["scenarios"]
|
|
195
|
-
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
196
|
-
}
|
|
197
|
-
required = {item["id"] for item in expected["scenarios"]}
|
|
198
|
-
if set(stored) != required:
|
|
199
|
-
return False, "acceptance_manifest_scenarios_changed"
|
|
200
|
-
for item in expected["scenarios"]:
|
|
201
|
-
current = stored[item["id"]]
|
|
202
|
-
for field in _IMMUTABLE_SCENARIO_FIELDS:
|
|
203
|
-
if current.get(field) != item.get(field):
|
|
204
|
-
return False, f"acceptance_manifest_field_changed:{item['id']}:{field}"
|
|
205
|
-
text = Path(task_file).read_text(encoding="utf-8")
|
|
206
|
-
for scenario in expected["scenarios"]:
|
|
207
|
-
owner = scenario["owner"]
|
|
208
|
-
section = re.search(rf"(?ms)^##\s+{re.escape(owner)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
209
|
-
refs = re.search(r"(?m)^- \*\*Acceptance-Refs\*\*:[ \t]*([^\n]*)", section.group(0)) if section else None
|
|
210
|
-
if not refs or scenario["id"] not in re.findall(r"\b[SEB]-\d+\b", refs.group(1)):
|
|
211
|
-
return False, "acceptance_manifest_owner_missing"
|
|
212
|
-
return True, ""
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def record_manual_evidence(manifest_file: str, scenario_id: str, confirmed_by: str, evidence: str) -> None:
|
|
216
|
-
if not confirmed_by or confirmed_by.lower().split(":", 1)[0] in {"agent", "assistant", "codex", "claude", "opencode", "costrict"}:
|
|
217
|
-
raise ValueError("manual evidence must be confirmed by a user")
|
|
218
|
-
path = Path(manifest_file)
|
|
219
|
-
data = load_manifest(path)
|
|
220
|
-
scenarios = data.get("scenarios")
|
|
221
|
-
if not isinstance(scenarios, list):
|
|
222
|
-
raise ValueError("invalid manifest scenarios")
|
|
223
|
-
found = False
|
|
224
|
-
owner = ""
|
|
225
|
-
task_file = data.get("task_file")
|
|
226
|
-
for item in scenarios:
|
|
227
|
-
if isinstance(item, dict) and item.get("id") == scenario_id:
|
|
228
|
-
if item.get("kind") != "manual":
|
|
229
|
-
raise ValueError("scenario is not manual")
|
|
230
|
-
item["status"] = "verified"
|
|
231
|
-
item["evidence"] = {"confirmed_by": confirmed_by, "evidence": evidence}
|
|
232
|
-
found = True
|
|
233
|
-
owner = item.get("owner") if isinstance(item.get("owner"), str) else ""
|
|
234
|
-
if not found:
|
|
235
|
-
raise ValueError("scenario not found")
|
|
236
|
-
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
237
|
-
if isinstance(task_file, str) and task_file:
|
|
238
|
-
task_path = Path(task_file)
|
|
239
|
-
if not task_path.is_absolute():
|
|
240
|
-
task_path = path.parent / task_path
|
|
241
|
-
_sync_task_evidence(task_path, scenario_id, confirmed_by, evidence, owner)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _sync_task_evidence(task_file: Path, scenario_id: str, confirmed_by: str, evidence: str, owner: str = "") -> None:
|
|
245
|
-
from cf_acceptance_evidence import sync_task_evidence
|
|
246
|
-
sync_task_evidence(task_file, scenario_id, confirmed_by, evidence, owner)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
250
|
-
parser = argparse.ArgumentParser()
|
|
251
|
-
parser.add_argument("--task-file", required=True)
|
|
252
|
-
parser.add_argument("--output", default="")
|
|
253
|
-
parser.add_argument("--verify-plan", action="store_true")
|
|
254
|
-
parser.add_argument("--task-dir", default="")
|
|
255
|
-
parser.add_argument("--record-manual", action="store_true")
|
|
256
|
-
parser.add_argument("--scenario-id", default="")
|
|
257
|
-
parser.add_argument("--confirmed-by", default="")
|
|
258
|
-
parser.add_argument("--evidence", default="")
|
|
259
|
-
args = parser.parse_args(argv)
|
|
260
|
-
try:
|
|
261
|
-
if args.record_manual:
|
|
262
|
-
if not args.output or not args.scenario_id or not args.evidence:
|
|
263
|
-
raise ValueError("--record-manual requires --output, --scenario-id and --evidence")
|
|
264
|
-
record_manual_evidence(args.output, args.scenario_id, args.confirmed_by, args.evidence)
|
|
265
|
-
stdout.write(json.dumps({"ok": True, "scenario": args.scenario_id}, ensure_ascii=False))
|
|
266
|
-
return 0
|
|
267
|
-
if args.verify_plan:
|
|
268
|
-
if not args.task_dir:
|
|
269
|
-
raise ValueError("--verify-plan requires --task-dir")
|
|
270
|
-
from cf_spec_context import load_context
|
|
271
|
-
from cf_spec_gate import result_to_data, validate_plan_coverage, validate_stage
|
|
272
|
-
|
|
273
|
-
context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
|
|
274
|
-
gate = validate_stage(context, "plan")
|
|
275
|
-
coverage = validate_plan_coverage(context, args.task_file)
|
|
276
|
-
result = {
|
|
277
|
-
"decision": "block" if gate.decision == "block" or coverage.decision == "block" else "pass",
|
|
278
|
-
"gate": result_to_data(gate),
|
|
279
|
-
"coverage": result_to_data(coverage),
|
|
280
|
-
}
|
|
281
|
-
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
282
|
-
return 0 if result["decision"] == "pass" else 3
|
|
283
|
-
if not args.output:
|
|
284
|
-
raise ValueError("--output is required unless --verify-plan is used")
|
|
285
|
-
manifest = write_manifest(args.task_file, args.output)
|
|
286
|
-
stdout.write(json.dumps({"ok": True, "scenarios": len(manifest["scenarios"])}, ensure_ascii=False))
|
|
287
|
-
return 0
|
|
288
|
-
except (OSError, ValueError) as exc:
|
|
289
|
-
stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
290
|
-
return 2
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if __name__ == "__main__":
|
|
294
|
-
raise SystemExit(main())
|