diff --git a/.sopify-skills/plan/20260522_runtime_slimming_kernel_extraction/tasks.md b/.sopify-skills/plan/20260522_runtime_slimming_kernel_extraction/tasks.md index 9a4ea0a..4b5c40e 100644 --- a/.sopify-skills/plan/20260522_runtime_slimming_kernel_extraction/tasks.md +++ b/.sopify-skills/plan/20260522_runtime_slimming_kernel_extraction/tasks.md @@ -36,7 +36,7 @@ archive_ready: false | 1. 蓝图 delta 校验 | ✅ 完成 | 5 项审计全通过 | | 2. 当前消费者扫描 | ✅ 完成 | 4 类清单 + consumer 判定 | | 3. 删除就绪结论 | ✅ 完成 | kernel 边界锁定, 退场量级 ~38K LOC | -| 4. 审计后删除 | ✅ 主线完成 | 4.1-4.5/4.6/4.7-4.10a/4.10c/4.10d/4.13-A/4.13-B ✅; 4.10b/4.11/4.12/4.13 Phase B 待后续 | +| 4. 审计后删除 | ✅ 主线完成 | 4.1-4.5/4.6/4.7-4.10a/4.10b/4.10c/4.10d/4.13-A/4.13-B ✅; 4.11/4.12/4.13 Phase B 待后续 | | 5. 文档更新 | ⚠️ 部分完成 | 5.1/5.2/5.3 ✅; 5.4/5.5/5.6/5.7 待后续 | | 6. contract 面清理 + engine 重构 | ✅ 完成 | 6.1-6.6 全部收完, −6,400+ LOC | @@ -183,7 +183,7 @@ archive_ready: false > - run_runtime() 兼容 wrapper 仍在 engine.py,尚未删除 > - _kernel_turn 仍包含 11 个 non-kernel route handler 的分发逻辑 > - 以上均属 Package A 范围 -- [ ] 4.10b **Step 3 Package A: _kernel_turn → engine 依赖切断 + 合同面审计 + 批量删除** — re-scoped / partial close (2026-05-23) +- [x] 4.10b **Step 3 Package A: _kernel_turn → engine 依赖切断 + 合同面审计 + 批量删除** — re-scoped / partial close (2026-05-23); **plan_scaffold 单职责重构 + runtime/plan/ 包化** ✅ 完成 (2026-05-25) > 判断边界: 按"当前宿主可见 contract 还在不在"删,不按模块名猜测。行为还需要但文件不需要时,优先内联到 retained 模块;不新造模块/层次/public surface。 > > **A1: _kernel_turn → engine 依赖切断(仅切实现耦合,不删功能面)** ✅ 完成 @@ -211,7 +211,7 @@ archive_ready: false > | `plan_registry.py` | 953 | **retain as independent governance layer** ✅ 6.5 裁定 | _planning.py + archive_lifecycle + output; YAML 写入已迁出到 _yaml.py | > | `skill_registry.py` | — | **deleted** ✅ 6.4 | discovery 退场 | > | `skill_resolver.py` | — | **deleted** ✅ 6.4 | resolve_route_candidate_skills 退场,candidate_skill_ids 改为静态 tuple | - > | `plan_scaffold.py` | 466 | **retain as single-purpose module** ✅ 维护者确认 | 6.6b 后 runtime 消费者仅 _planning.py; tests 消费者仍多。暂不内联,后续只做主链单职责瘦身 | + > | `plan_scaffold.py` | 466→~200 | **refactored → runtime/plan/ package** ✅ 4.10b 完成 (2026-05-25) | 拆为 scaffold/lookup/intent/identity 4 个单职责模块 + registry.py 迁入; create_plan_scaffold() registry side-effect 上提到 _planning.py; 626 tests green | > | `skill_runner.py` | — | **deleted** ✅ 4.10b A3 | 悬空路径 | > > 5 个模块 (archive_lifecycle / kb / clarification / decision / context_recovery) 经维护者确认为 retain as module。 @@ -222,7 +222,7 @@ archive_ready: false > **A3: 立即删除面** ✅ 收口 > - 已完成: runtime skill execution sidecar (-187 LOC) ✅ 2141ed6 > - 否决: 38 项大内联方案(~1,655 LOC 搬进 _kernel_turn.py 是换文件名不收缩) - > - 剩余: plan_scaffold.py (464 LOC) 保留独立模块;后续若继续瘦身,仅做职责收窄审计与测试收口,不再以“内联/删文件”为目标 + > - 剩余: plan_scaffold.py 已完成单职责重构 ✅ (2026-05-25):拆为 runtime/plan/{scaffold,lookup,intent,identity,registry}.py; create_plan_scaffold() 不再含 registry write > - engine.py: 6.6 完成后已瘦身至 343 LOC(conflict/cancel + activation + archive + run_runtime wrapper);planning 主块已迁出到 _planning.py > - S3.1 大 co-delete 表不再作为执行清单;已降级为旧假设 - [x] 4.10c Step 3 Package C: models.py bridge 退场 ✅ 完成 (2026-05-23) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4d266..60b45c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,51 @@ Format: Summary → Changed → Plan Packages. File-level details live in `git l ## [Unreleased] +## [2026-05-26.092824] - 2026-05-26 + +### Summary + +- Changes across: Docs, Runtime, Skills, Tests. + +### Changed + +- **Docs**: Refined public documentation (2 files) +- **Runtime**: Updated runtime internals (12 files) +- **Skills**: Synced prompt-layer skills (4 files) +- **Tests**: Updated automated coverage (6 files) + +## [2026-05-26.092444] - 2026-05-26 + +### Summary + +- Changes across: Runtime. + +### Changed + +- **Runtime**: Updated runtime internals (1 files) + +## [2026-05-26.092057] - 2026-05-26 + +### Summary + +- Changes across: Runtime, Tests. + +### Changed + +- **Runtime**: Updated runtime internals (15 files) +- **Tests**: Updated automated coverage (5 files) + +## [2026-05-25.194723] - 2026-05-25 + +### Summary + +- Changes across: Runtime, Tests. + +### Changed + +- **Runtime**: Updated runtime internals (5 files) +- **Tests**: Updated automated coverage (5 files) + ## [2026-05-24.205420] - 2026-05-24 ### Summary diff --git a/Claude/Skills/CN/CLAUDE.md b/Claude/Skills/CN/CLAUDE.md index 5d4dbc2..4cfc497 100644 --- a/Claude/Skills/CN/CLAUDE.md +++ b/Claude/Skills/CN/CLAUDE.md @@ -1,5 +1,5 @@ - + # Sopify - 自适应 AI 编程助手 diff --git a/Claude/Skills/EN/CLAUDE.md b/Claude/Skills/EN/CLAUDE.md index c9f535a..09cf005 100644 --- a/Claude/Skills/EN/CLAUDE.md +++ b/Claude/Skills/EN/CLAUDE.md @@ -1,5 +1,5 @@ - + # Sopify - Adaptive AI Programming Assistant diff --git a/Codex/Skills/CN/AGENTS.md b/Codex/Skills/CN/AGENTS.md index 0849e4c..916e017 100644 --- a/Codex/Skills/CN/AGENTS.md +++ b/Codex/Skills/CN/AGENTS.md @@ -1,5 +1,5 @@ - + # Sopify - 自适应 AI 编程助手 diff --git a/Codex/Skills/EN/AGENTS.md b/Codex/Skills/EN/AGENTS.md index e8642e5..dac2b3d 100644 --- a/Codex/Skills/EN/AGENTS.md +++ b/Codex/Skills/EN/AGENTS.md @@ -1,5 +1,5 @@ - + # Sopify - Adaptive AI Programming Assistant diff --git a/README.md b/README.md index eccce6c..a57e874 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE) [![Docs](https://img.shields.io/badge/docs-CC%20BY%204.0-green.svg)](./LICENSE-docs) -[![Version](https://img.shields.io/badge/version-2026--05--24.205420-orange.svg)](#version-history) +[![Version](https://img.shields.io/badge/version-2026--05--26.092824-orange.svg)](#version-history) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md) English · [简体中文](./README.zh-CN.md) · [Quick Start](#quick-start) · [Contributors](./CONTRIBUTORS.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index e8e2352..217f021 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ [![许可证](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE) [![文档](https://img.shields.io/badge/docs-CC%20BY%204.0-green.svg)](./LICENSE-docs) -[![版本](https://img.shields.io/badge/version-2026--05--24.205420-orange.svg)](#版本历史) +[![版本](https://img.shields.io/badge/version-2026--05--26.092824-orange.svg)](#版本历史) [![欢迎PR](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING_CN.md) [English](./README.md) · 简体中文 · [快速开始](#快速开始) · [贡献者](./CONTRIBUTORS.md) diff --git a/runtime/_planning.py b/runtime/_planning.py index 50f489c..3718a00 100644 --- a/runtime/_planning.py +++ b/runtime/_planning.py @@ -28,15 +28,15 @@ from sopify_contracts.artifacts import KbArtifact, PlanArtifact from sopify_contracts.core import ExecutionGate, RouteDecision, RunState, RuntimeConfig from sopify_contracts.decision import ClarificationState, DecisionState -from .plan_registry import ( +from .plan.registry import ( + PlanRegistryError, encode_priority_note_event, priority_note_for_plan, + upsert_plan_entry, ) -from .plan_scaffold import ( - create_plan_scaffold, - find_plan_by_request_reference, - request_explicitly_wants_new_plan, -) +from .plan.scaffold import create_plan_scaffold +from .plan.lookup import find_plan_by_request_reference +from .plan.intent import request_explicitly_wants_new_plan from canonical_writer import StateStore, iso_now from .state import ( make_run_id, @@ -917,6 +917,14 @@ def _advance_planning_route( level=level, decision_state=confirmed_decision, ) + try: + upsert_plan_entry( + config=config, + artifact=created, + request_text=decision.request_text, + ) + except PlanRegistryError: + pass state_store.set_current_plan(created) kb_artifact = _merge_kb_artifacts(kb_artifact, ensure_blueprint_index(config), config=config) notes.extend( diff --git a/runtime/archive_lifecycle.py b/runtime/archive_lifecycle.py index 045fb15..9caac19 100644 --- a/runtime/archive_lifecycle.py +++ b/runtime/archive_lifecycle.py @@ -15,7 +15,7 @@ from .knowledge_sync import KNOWLEDGE_SYNC_KEYS, knowledge_sync_targets, parse_knowledge_sync from sopify_contracts.artifacts import KbArtifact, PlanArtifact from sopify_contracts.core import RuntimeConfig -from .plan_registry import PlanRegistryError, remove_plan_entry +from .plan.registry import PlanRegistryError, remove_plan_entry from canonical_writer import StateStore, iso_now ARCHIVE_STATUS_COMPLETED = "completed" diff --git a/runtime/engine.py b/runtime/engine.py index 1afeaea..147eafd 100644 --- a/runtime/engine.py +++ b/runtime/engine.py @@ -26,7 +26,7 @@ from sopify_contracts.core import RouteDecision, RunState, RuntimeConfig, SkillMeta from sopify_contracts.decision import ClarificationState, DecisionState from sopify_contracts.handoff import RuntimeHandoff, RuntimeResult, SkillActivation -from .plan_registry import ( +from .plan.registry import ( PlanRegistryError, get_plan_entry, registry_relative_path, diff --git a/runtime/output.py b/runtime/output.py index 23e93d0..a17e2a4 100644 --- a/runtime/output.py +++ b/runtime/output.py @@ -9,7 +9,7 @@ from .decision import CURRENT_DECISION_RELATIVE_PATH from .handoff import CURRENT_HANDOFF_RELATIVE_PATH from sopify_contracts.handoff import RuntimeResult -from .plan_registry import extract_priority_note_event +from .plan.registry import extract_priority_note_event _PHASE_LABELS = { "zh-CN": { diff --git a/runtime/plan/__init__.py b/runtime/plan/__init__.py new file mode 100644 index 0000000..adbfdb9 --- /dev/null +++ b/runtime/plan/__init__.py @@ -0,0 +1,9 @@ +"""Plan subsystem for Sopify runtime. + +Submodules: +- scaffold: plan package creation and rendering +- lookup: plan discovery and loading from disk +- intent: new-plan intent detection (text heuristics) +- identity: shared naming helpers (derive_topic_key) +- registry: plan governance and priority tracking +""" diff --git a/runtime/plan/identity.py b/runtime/plan/identity.py new file mode 100644 index 0000000..06b292f --- /dev/null +++ b/runtime/plan/identity.py @@ -0,0 +1,21 @@ +"""Plan identity helpers shared by scaffold creation and plan lookup.""" + +from __future__ import annotations + +from hashlib import sha1 +import re + + +def derive_topic_key(request_text: str) -> str: + cleaned = " ".join(request_text.split()) + if not cleaned: + return "task" + normalized = _slugify(cleaned)[:48].rstrip("-") + if normalized: + return normalized + return f"task-{sha1(cleaned.encode('utf-8')).hexdigest()[:6]}" + + +def _slugify(value: str) -> str: + ascii_slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return ascii_slug or "task" diff --git a/runtime/plan/intent.py b/runtime/plan/intent.py new file mode 100644 index 0000000..9eb40c9 --- /dev/null +++ b/runtime/plan/intent.py @@ -0,0 +1,66 @@ +"""Plan intent detection for Sopify runtime. + +Determines whether a user request explicitly asks for a new plan, +distinguishing genuine "create new plan" phrases from negated forms. +""" + +from __future__ import annotations + +import re +from typing import Sequence + +_EXPLICIT_NEW_PLAN_PATTERNS = ( + re.compile(r"\bnew\s+plan\b", re.IGNORECASE), + re.compile(r"\bcreate\s+(?:a\s+)?new\s+plan\b", re.IGNORECASE), + re.compile(r"新建(?:一个)?\s*plan", re.IGNORECASE), + re.compile(r"新\s*plan", re.IGNORECASE), + re.compile(r"新的\s*plan", re.IGNORECASE), + re.compile(r"另起(?:一个)?\s*plan", re.IGNORECASE), + re.compile(r"新增(?:一个)?\s*plan", re.IGNORECASE), +) +_NEGATED_NEW_PLAN_PATTERNS = ( + re.compile( + r"(?:不要|别|不用|无需|禁止)\s*(?:再|另外|额外|单独)?\s*(?:新建(?:一个)?(?:新的)?\s*plan|新\s*plan|新的\s*plan)", + re.IGNORECASE, + ), + re.compile(r"(?:do\s+not|don't|dont|no\s+need\s+to)\s+(?:create\s+(?:a\s+)?new\s+plan|new\s+plan)", re.IGNORECASE), +) + + +def request_explicitly_wants_new_plan(request_text: str) -> bool: + normalized = " ".join(request_text.split()) + matches = _collect_new_plan_intent_matches(normalized) + effective_matches = _drop_positive_matches_covered_by_negated(matches) + if not effective_matches: + return False + last_match = max(effective_matches, key=lambda item: (item[0], item[1])) + return last_match[2] == "positive" + + +def _collect_new_plan_intent_matches(text: str) -> list[tuple[int, int, str]]: + seen: set[tuple[int, int, str]] = set() + ordered: list[tuple[int, int, str]] = [] + for polarity, patterns in ( + ("negated", _NEGATED_NEW_PLAN_PATTERNS), + ("positive", _EXPLICIT_NEW_PLAN_PATTERNS), + ): + for pattern in patterns: + for match in pattern.finditer(text): + span = (match.start(), match.end(), polarity) + if span in seen: + continue + seen.add(span) + ordered.append(span) + return ordered + + +def _drop_positive_matches_covered_by_negated( + matches: Sequence[tuple[int, int, str]], +) -> list[tuple[int, int, str]]: + negated_spans = [(start, end) for start, end, polarity in matches if polarity == "negated"] + effective: list[tuple[int, int, str]] = [] + for start, end, polarity in matches: + if polarity == "positive" and any(neg_start <= start and end <= neg_end for neg_start, neg_end in negated_spans): + continue + effective.append((start, end, polarity)) + return effective diff --git a/runtime/plan/lookup.py b/runtime/plan/lookup.py new file mode 100644 index 0000000..5bc19dd --- /dev/null +++ b/runtime/plan/lookup.py @@ -0,0 +1,135 @@ +"""Plan discovery and loading for Sopify runtime. + +Provides functions to find existing plan artifacts by reference or topic key, +and to reconstruct ``PlanArtifact`` objects from on-disk plan directories. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import re +from typing import Mapping + +from .._yaml import YamlParseError, load_yaml +from .identity import derive_topic_key +from sopify_contracts.artifacts import PlanArtifact +from sopify_contracts.core import RuntimeConfig + +_FRONT_MATTER_RE = re.compile(r"\A---\n(?P.*?)\n---\n(?P.*)\Z", re.DOTALL) +_PLAN_REFERENCE_RE = re.compile(r"(?P\d{8}_[a-z0-9][a-z0-9_.-]*)", re.IGNORECASE) + + +def find_plan_by_request_reference(request_text: str, *, config: RuntimeConfig) -> PlanArtifact | None: + for match in _PLAN_REFERENCE_RE.finditer(request_text): + plan_id = (match.group("plan_id") or "").strip() + if not plan_id: + continue + artifact = load_plan_artifact(config.plan_root / plan_id, config=config) + if artifact is not None: + return artifact + return None + + +def find_plan_by_topic_key(topic_key: str, *, config: RuntimeConfig) -> PlanArtifact | None: + matches: list[PlanArtifact] = [] + plan_root = config.plan_root + if not plan_root.exists(): + return None + for plan_dir in sorted(plan_root.iterdir()): + artifact = load_plan_artifact(plan_dir, config=config) + if artifact is None: + continue + candidate_topic_key = artifact.topic_key or derive_topic_key(artifact.title) + if candidate_topic_key == topic_key: + matches.append(artifact) + if len(matches) > 1: + return None + return matches[0] if len(matches) == 1 else None + + +def load_plan_artifact(plan_dir: Path, *, config: RuntimeConfig) -> PlanArtifact | None: + if not plan_dir.exists() or not plan_dir.is_dir(): + return None + + metadata_path = _pick_metadata_file(plan_dir) + if metadata_path is None: + return None + + metadata, body = _load_plan_metadata(metadata_path) + if metadata is None: + return None + + plan_id = str(metadata.get("plan_id") or plan_dir.name) + level = str(metadata.get("level") or ("light" if metadata_path.name == "plan.md" else "standard")) + title = _extract_title(body) or plan_id + summary = _extract_summary(body, fallback=title) + topic_key = str(metadata.get("topic_key") or metadata.get("feature_key") or derive_topic_key(title)) + files = tuple(str(path.relative_to(config.workspace_root)) for path in _collect_plan_files(plan_dir)) + created_at = _path_created_at(metadata_path) + + return PlanArtifact( + plan_id=plan_id, + title=title, + summary=summary, + level=level, + path=str(plan_dir.relative_to(config.workspace_root)), + files=files, + created_at=created_at, + topic_key=topic_key, + ) + + +def _pick_metadata_file(plan_dir: Path) -> Path | None: + for filename in ("plan.md", "tasks.md"): + candidate = plan_dir / filename + if candidate.exists() and candidate.is_file(): + return candidate + return None + + +def _load_plan_metadata(metadata_path: Path) -> tuple[Mapping[str, object] | None, str]: + raw_text = metadata_path.read_text(encoding="utf-8") + match = _FRONT_MATTER_RE.match(raw_text) + if match is None: + return None, raw_text + front_matter = match.group("front") + body = match.group("body") + try: + metadata = load_yaml(front_matter) + except YamlParseError: + return None, body + if not isinstance(metadata, Mapping): + return None, body + return metadata, body + + +def _collect_plan_files(plan_dir: Path) -> list[Path]: + collected: list[Path] = [] + for child in sorted(plan_dir.iterdir()): + collected.append(child) + return collected + + +def _extract_title(body: str) -> str: + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return "" + + +def _extract_summary(body: str, *, fallback: str) -> str: + lines = [line.strip() for line in body.splitlines() if line.strip()] + if not lines: + return fallback + for index, line in enumerate(lines): + if line.startswith("# "): + if index + 1 < len(lines): + return lines[index + 1] + break + return lines[0] + + +def _path_created_at(path: Path) -> str: + return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat() diff --git a/runtime/plan_registry.py b/runtime/plan/registry.py similarity index 99% rename from runtime/plan_registry.py rename to runtime/plan/registry.py index d2011de..1b8682d 100644 --- a/runtime/plan_registry.py +++ b/runtime/plan/registry.py @@ -9,7 +9,7 @@ from tempfile import NamedTemporaryFile from typing import Any, Mapping, Sequence -from ._yaml import YamlParseError, dump_yaml, load_yaml +from .._yaml import YamlParseError, dump_yaml, load_yaml from sopify_contracts.artifacts import PlanArtifact from sopify_contracts.core import RuntimeConfig from canonical_writer import StateStore, iso_now diff --git a/runtime/plan_scaffold.py b/runtime/plan/scaffold.py similarity index 54% rename from runtime/plan_scaffold.py rename to runtime/plan/scaffold.py index 0f77deb..1bbe40f 100644 --- a/runtime/plan_scaffold.py +++ b/runtime/plan/scaffold.py @@ -2,40 +2,19 @@ from __future__ import annotations -from datetime import datetime, timezone -from hashlib import sha1 +from datetime import datetime from pathlib import Path import re -from typing import Iterable, List, Mapping, Sequence +from typing import List -from ._yaml import YamlParseError, load_yaml -from .decision import option_by_id -from .knowledge_sync import render_knowledge_sync_front_matter +from ..decision import option_by_id +from ..knowledge_sync import render_knowledge_sync_front_matter +from .identity import derive_topic_key from sopify_contracts.artifacts import PlanArtifact from sopify_contracts.core import RuntimeConfig from sopify_contracts.decision import DecisionState -from .plan_registry import PlanRegistryError, upsert_plan_entry from canonical_writer import iso_now -_FRONT_MATTER_RE = re.compile(r"\A---\n(?P.*?)\n---\n(?P.*)\Z", re.DOTALL) -_PLAN_REFERENCE_RE = re.compile(r"(?P\d{8}_[a-z0-9][a-z0-9_.-]*)", re.IGNORECASE) -_EXPLICIT_NEW_PLAN_PATTERNS = ( - re.compile(r"\bnew\s+plan\b", re.IGNORECASE), - re.compile(r"\bcreate\s+(?:a\s+)?new\s+plan\b", re.IGNORECASE), - re.compile(r"新建(?:一个)?\s*plan", re.IGNORECASE), - re.compile(r"新\s*plan", re.IGNORECASE), - re.compile(r"新的\s*plan", re.IGNORECASE), - re.compile(r"另起(?:一个)?\s*plan", re.IGNORECASE), - re.compile(r"新增(?:一个)?\s*plan", re.IGNORECASE), -) -_NEGATED_NEW_PLAN_PATTERNS = ( - re.compile( - r"(?:不要|别|不用|无需|禁止)\s*(?:再|另外|额外|单独)?\s*(?:新建(?:一个)?(?:新的)?\s*plan|新\s*plan|新的\s*plan)", - re.IGNORECASE, - ), - re.compile(r"(?:do\s+not|don't|dont|no\s+need\s+to)\s+(?:create\s+(?:a\s+)?new\s+plan|new\s+plan)", re.IGNORECASE), -) - def create_plan_scaffold( request_text: str, @@ -125,15 +104,6 @@ def create_plan_scaffold( created_at=iso_now(), topic_key=resolved_topic_key, ) - try: - upsert_plan_entry( - config=config, - artifact=artifact, - request_text=request_text, - ) - except PlanRegistryError: - # Governance sync must not block core plan creation. - pass return artifact @@ -160,115 +130,6 @@ def _derive_title(request_text: str) -> str: return first_line[:45].rstrip() + "..." -def derive_topic_key(request_text: str) -> str: - cleaned = " ".join(request_text.split()) - if not cleaned: - return "task" - normalized = _slugify(cleaned)[:48].rstrip("-") - if normalized: - return normalized - return f"task-{sha1(cleaned.encode('utf-8')).hexdigest()[:6]}" - - -def request_explicitly_wants_new_plan(request_text: str) -> bool: - normalized = " ".join(request_text.split()) - matches = _collect_new_plan_intent_matches(normalized) - effective_matches = _drop_positive_matches_covered_by_negated(matches) - if not effective_matches: - return False - last_match = max(effective_matches, key=lambda item: (item[0], item[1])) - return last_match[2] == "positive" - - -def _collect_new_plan_intent_matches(text: str) -> list[tuple[int, int, str]]: - seen: set[tuple[int, int, str]] = set() - ordered: list[tuple[int, int, str]] = [] - for polarity, patterns in ( - ("negated", _NEGATED_NEW_PLAN_PATTERNS), - ("positive", _EXPLICIT_NEW_PLAN_PATTERNS), - ): - for pattern in patterns: - for match in pattern.finditer(text): - span = (match.start(), match.end(), polarity) - if span in seen: - continue - seen.add(span) - ordered.append(span) - return ordered - - -def _drop_positive_matches_covered_by_negated( - matches: Sequence[tuple[int, int, str]], -) -> list[tuple[int, int, str]]: - negated_spans = [(start, end) for start, end, polarity in matches if polarity == "negated"] - effective: list[tuple[int, int, str]] = [] - for start, end, polarity in matches: - if polarity == "positive" and any(neg_start <= start and end <= neg_end for neg_start, neg_end in negated_spans): - continue - effective.append((start, end, polarity)) - return effective - - -def find_plan_by_request_reference(request_text: str, *, config: RuntimeConfig) -> PlanArtifact | None: - for match in _PLAN_REFERENCE_RE.finditer(request_text): - plan_id = (match.group("plan_id") or "").strip() - if not plan_id: - continue - artifact = load_plan_artifact(config.plan_root / plan_id, config=config) - if artifact is not None: - return artifact - return None - - -def find_plan_by_topic_key(topic_key: str, *, config: RuntimeConfig) -> PlanArtifact | None: - matches: list[PlanArtifact] = [] - plan_root = config.plan_root - if not plan_root.exists(): - return None - for plan_dir in sorted(plan_root.iterdir()): - artifact = load_plan_artifact(plan_dir, config=config) - if artifact is None: - continue - candidate_topic_key = artifact.topic_key or derive_topic_key(artifact.title) - if candidate_topic_key == topic_key: - matches.append(artifact) - if len(matches) > 1: - return None - return matches[0] if len(matches) == 1 else None - - -def load_plan_artifact(plan_dir: Path, *, config: RuntimeConfig) -> PlanArtifact | None: - if not plan_dir.exists() or not plan_dir.is_dir(): - return None - - metadata_path = _pick_metadata_file(plan_dir) - if metadata_path is None: - return None - - metadata, body = _load_plan_metadata(metadata_path) - if metadata is None: - return None - - plan_id = str(metadata.get("plan_id") or plan_dir.name) - level = str(metadata.get("level") or ("light" if metadata_path.name == "plan.md" else "standard")) - title = _extract_title(body) or plan_id - summary = _extract_summary(body, fallback=title) - topic_key = str(metadata.get("topic_key") or metadata.get("feature_key") or derive_topic_key(title)) - files = tuple(str(path.relative_to(config.workspace_root)) for path in _collect_plan_files(plan_dir)) - created_at = _path_created_at(metadata_path) - - return PlanArtifact( - plan_id=plan_id, - title=title, - summary=summary, - level=level, - path=str(plan_dir.relative_to(config.workspace_root)), - files=files, - created_at=created_at, - topic_key=topic_key, - ) - - def _make_plan_id(topic_key: str, *, plan_root: Path) -> str: date_prefix = datetime.now().strftime("%Y%m%d") base = f"{date_prefix}_{topic_key}" @@ -280,9 +141,6 @@ def _make_plan_id(topic_key: str, *, plan_root: Path) -> str: return candidate -def _slugify(value: str) -> str: - ascii_slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return ascii_slug or "task" def _render_light_plan(title: str, summary: str, *, plan_id: str, feature_key: str, decision_state: DecisionState | None) -> str: return ( _render_plan_front_matter(plan_id=plan_id, feature_key=feature_key, level="light", decision_state=decision_state) @@ -409,58 +267,3 @@ def _render_decision_section(decision_state: DecisionState | None) -> str: "- 候选方案:\n" f"{options}\n\n" ) - - -def _pick_metadata_file(plan_dir: Path) -> Path | None: - for filename in ("plan.md", "tasks.md"): - candidate = plan_dir / filename - if candidate.exists() and candidate.is_file(): - return candidate - return None - - -def _load_plan_metadata(metadata_path: Path) -> tuple[Mapping[str, object] | None, str]: - raw_text = metadata_path.read_text(encoding="utf-8") - match = _FRONT_MATTER_RE.match(raw_text) - if match is None: - return None, raw_text - front_matter = match.group("front") - body = match.group("body") - try: - metadata = load_yaml(front_matter) - except YamlParseError: - return None, body - if not isinstance(metadata, Mapping): - return None, body - return metadata, body - - -def _collect_plan_files(plan_dir: Path) -> list[Path]: - collected: list[Path] = [] - for child in sorted(plan_dir.iterdir()): - collected.append(child) - return collected - - -def _extract_title(body: str) -> str: - for line in body.splitlines(): - stripped = line.strip() - if stripped.startswith("# "): - return stripped[2:].strip() - return "" - - -def _extract_summary(body: str, *, fallback: str) -> str: - lines = [line.strip() for line in body.splitlines() if line.strip()] - if not lines: - return fallback - for index, line in enumerate(lines): - if line.startswith("# "): - if index + 1 < len(lines): - return lines[index + 1] - break - return lines[0] - - -def _path_created_at(path: Path) -> str: - return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).replace(microsecond=0).isoformat() diff --git a/tests/runtime_test_support.py b/tests/runtime_test_support.py index 3a57ffe..5515806 100644 --- a/tests/runtime_test_support.py +++ b/tests/runtime_test_support.py @@ -37,7 +37,7 @@ from runtime.handoff import build_runtime_handoff from runtime.kb import bootstrap_kb, ensure_blueprint_index from runtime.knowledge_layout import materialization_stage, resolve_context_profile -from runtime.plan_registry import ( +from runtime.plan.registry import ( PlanRegistryError, confirm_plan_priority, get_plan_entry, @@ -46,7 +46,8 @@ recommend_plan_candidates, registry_relative_path, ) -from runtime.plan_scaffold import create_plan_scaffold, request_explicitly_wants_new_plan +from runtime.plan.scaffold import create_plan_scaffold +from runtime.plan.intent import request_explicitly_wants_new_plan from runtime.output import render_runtime_output from runtime.preferences import preload_preferences, preload_preferences_for_workspace from runtime.router import Router diff --git a/tests/test_runtime_gate.py b/tests/test_runtime_gate.py index a404a17..fbccd58 100644 --- a/tests/test_runtime_gate.py +++ b/tests/test_runtime_gate.py @@ -39,7 +39,7 @@ from sopify_contracts.core import RouteDecision, RunState from sopify_contracts.decision import ClarificationState, DecisionOption, DecisionState from sopify_contracts.handoff import RuntimeHandoff -from runtime.plan_scaffold import create_plan_scaffold +from runtime.plan.scaffold import create_plan_scaffold from canonical_writer import StateStore, iso_now from runtime.state import stable_request_sha1 from runtime.workspace_preflight import _drop_cli_arg_pairs diff --git a/tests/test_runtime_plan_intent.py b/tests/test_runtime_plan_intent.py new file mode 100644 index 0000000..7804d97 --- /dev/null +++ b/tests/test_runtime_plan_intent.py @@ -0,0 +1,26 @@ +# Test classification: contract +from __future__ import annotations + +from tests.runtime_test_support import * +from runtime.plan.intent import request_explicitly_wants_new_plan + + +class PlanIntentTests(unittest.TestCase): + def test_explicit_new_plan_patterns_ignore_ambiguous_other_plan_phrase(self) -> None: + self.assertFalse(request_explicitly_wants_new_plan("分析这个方案和其他 plan 的差异")) + self.assertTrue(request_explicitly_wants_new_plan("请新建一个 plan 处理这个问题")) + + def test_explicit_new_plan_patterns_respect_local_negation_without_global_blocking(self) -> None: + self.assertFalse(request_explicitly_wants_new_plan("不要新建新的 plan 包,直接在当前 plan 上继续细化")) + self.assertTrue(request_explicitly_wants_new_plan("不要复用当前 plan,直接新建 plan")) + self.assertTrue(request_explicitly_wants_new_plan("不是不要新建 plan,而是要新建 plan")) + self.assertTrue(request_explicitly_wants_new_plan("do not create a new plan; create a new plan now")) + + def test_empty_input_returns_false(self) -> None: + self.assertFalse(request_explicitly_wants_new_plan("")) + self.assertFalse(request_explicitly_wants_new_plan(" ")) + + def test_chinese_new_plan_phrase_variants(self) -> None: + self.assertTrue(request_explicitly_wants_new_plan("另起一个 plan 来做")) + self.assertTrue(request_explicitly_wants_new_plan("新增 plan 处理这个需求")) + self.assertFalse(request_explicitly_wants_new_plan("禁止新建 plan")) diff --git a/tests/test_runtime_plan_lookup.py b/tests/test_runtime_plan_lookup.py new file mode 100644 index 0000000..dfc63ad --- /dev/null +++ b/tests/test_runtime_plan_lookup.py @@ -0,0 +1,69 @@ +# Test classification: contract +from __future__ import annotations + +from tests.runtime_test_support import * +from runtime.plan.lookup import ( + find_plan_by_request_reference, + find_plan_by_topic_key, + load_plan_artifact, +) + + +class PlanLookupTests(unittest.TestCase): + def test_find_plan_by_topic_key_returns_matching_artifact(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + config = load_runtime_config(workspace) + + artifact = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + + found = find_plan_by_topic_key(artifact.topic_key, config=config) + self.assertIsNotNone(found) + assert found is not None + self.assertEqual(found.plan_id, artifact.plan_id) + self.assertEqual(found.topic_key, artifact.topic_key) + + def test_find_plan_by_topic_key_returns_none_for_missing(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + config = load_runtime_config(workspace) + + found = find_plan_by_topic_key("nonexistent-topic", config=config) + self.assertIsNone(found) + + def test_load_plan_artifact_reconstructs_from_disk(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir).resolve() + config = load_runtime_config(workspace) + + artifact = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + + loaded = load_plan_artifact(workspace / artifact.path, config=config) + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual(loaded.plan_id, artifact.plan_id) + self.assertIn(artifact.title, loaded.title) + self.assertEqual(loaded.topic_key, artifact.topic_key) + + def test_find_plan_by_request_reference_extracts_plan_id(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + config = load_runtime_config(workspace) + + artifact = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + + found = find_plan_by_request_reference( + f"请查看 plan:{artifact.plan_id} 的进展", + config=config, + ) + self.assertIsNotNone(found) + assert found is not None + self.assertEqual(found.plan_id, artifact.plan_id) + + def test_find_plan_by_request_reference_returns_none_for_no_match(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + config = load_runtime_config(workspace) + + found = find_plan_by_request_reference("没有任何引用", config=config) + self.assertIsNone(found) diff --git a/tests/test_runtime_plan_registry.py b/tests/test_runtime_plan_registry.py index 5adbdd0..b1d082a 100644 --- a/tests/test_runtime_plan_registry.py +++ b/tests/test_runtime_plan_registry.py @@ -3,45 +3,38 @@ from tests.runtime_test_support import * from runtime.archive_lifecycle import apply_archive_subject, resolve_archive_subject +from runtime.plan.registry import upsert_plan_entry +from sopify_contracts.core import RuntimeConfig + + +def _scaffold_with_registry(request_text: str, *, config: RuntimeConfig, level: str) -> PlanArtifact: + """Create a scaffold and sync it to the registry (mirrors _planning.py caller behavior).""" + artifact = create_plan_scaffold(request_text, config=config, level=level) + upsert_plan_entry(config=config, artifact=artifact, request_text=request_text) + return artifact class PlanRegistryTests(unittest.TestCase): - def test_plan_scaffold_auto_upserts_registry_with_suggested_priority(self) -> None: + def test_plan_scaffold_does_not_auto_upsert_registry(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: workspace = Path(temp_dir) config = load_runtime_config(workspace) - artifact = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") registry_file = workspace / registry_relative_path(config) - self.assertTrue(registry_file.exists()) - - read_result = read_plan_registry(config) - self.assertEqual(read_result.payload["mode"], "observe_only") - self.assertEqual(read_result.payload["selection_policy"], "explicit_only") - self.assertEqual(read_result.payload["priority_policy"], "heuristic_v1") - - entry_result = get_plan_entry(config=config, plan_id=artifact.plan_id) - self.assertIsNotNone(entry_result.entry) - assert entry_result.entry is not None - self.assertEqual(entry_result.entry["snapshot"]["path"], artifact.path) - self.assertEqual(entry_result.entry["snapshot"]["title"], artifact.title) - self.assertIsNone(entry_result.entry["governance"]["priority"]) - self.assertIsNone(entry_result.entry["governance"]["priority_source"]) - self.assertEqual(entry_result.entry["governance"]["status"], "todo") - self.assertEqual(entry_result.entry["advice"]["suggested_priority"], "p2") - self.assertTrue(entry_result.entry["advice"]["suggested_reason"]) + self.assertFalse(registry_file.exists()) def test_missing_registry_backfills_existing_plan_dirs_on_next_create(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: workspace = Path(temp_dir) config = load_runtime_config(workspace) - first = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + first = _scaffold_with_registry("补 runtime 骨架", config=config, level="standard") registry_file = workspace / registry_relative_path(config) registry_file.unlink() - second = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + second = _scaffold_with_registry("实现 runtime skeleton", config=config, level="standard") read_result = read_plan_registry(config) plan_ids = {entry["plan_id"] for entry in read_result.payload["plans"]} @@ -52,7 +45,7 @@ def test_reconcile_updates_snapshot_without_overwriting_confirmed_governance(sel workspace = Path(temp_dir) config = load_runtime_config(workspace) - artifact = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + artifact = _scaffold_with_registry("实现 runtime skeleton", config=config, level="standard") confirm_plan_priority( config=config, plan_id=artifact.plan_id, @@ -92,7 +85,7 @@ def test_archive_removes_entry_from_active_registry(self) -> None: store = StateStore(config) store.ensure() - artifact = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + artifact = _scaffold_with_registry("实现 runtime skeleton", config=config, level="standard") store.set_current_plan(artifact) result = apply_archive_subject( @@ -124,9 +117,9 @@ def test_readonly_recommendations_keep_current_plan_and_explain_boundary(self) - store = StateStore(config) store.ensure() - current_plan = create_plan_scaffold("第一性原理协作规则分层落地", config=config, level="standard") + current_plan = _scaffold_with_registry("第一性原理协作规则分层落地", config=config, level="standard") store.set_current_plan(current_plan) - backlog_plan = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + backlog_plan = _scaffold_with_registry("补 runtime 骨架", config=config, level="standard") recommendations = recommend_plan_candidates(config=config) @@ -142,8 +135,8 @@ def test_recommendations_prefer_user_confirmed_priority_over_unconfirmed_suggest workspace = Path(temp_dir) config = load_runtime_config(workspace) - suggested_plan = create_plan_scaffold("紧急修 runtime blocker", config=config, level="standard") - confirmed_plan = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + suggested_plan = _scaffold_with_registry("紧急修 runtime blocker", config=config, level="standard") + confirmed_plan = _scaffold_with_registry("补 runtime 骨架", config=config, level="standard") confirm_plan_priority( config=config, plan_id=confirmed_plan.plan_id, @@ -162,12 +155,12 @@ def test_inspect_plan_registry_does_not_rewrite_registry_when_advice_is_unchange with tempfile.TemporaryDirectory() as temp_dir: workspace = Path(temp_dir) config = load_runtime_config(workspace) - artifact = create_plan_scaffold("实现 runtime skeleton", config=config, level="standard") + artifact = _scaffold_with_registry("实现 runtime skeleton", config=config, level="standard") registry_file = workspace / registry_relative_path(config) before = registry_file.read_text(encoding="utf-8") - with mock.patch("runtime.plan_registry.iso_now", return_value="2099-01-01T00:00:00+00:00"): + with mock.patch("runtime.plan.registry.iso_now", return_value="2099-01-01T00:00:00+00:00"): payload = inspect_plan_registry(config=config, plan_id=artifact.plan_id) after = registry_file.read_text(encoding="utf-8") @@ -198,7 +191,7 @@ def test_runtime_output_does_not_report_registry_when_registry_sync_failed(self) with tempfile.TemporaryDirectory() as temp_dir: workspace = Path(temp_dir) - with mock.patch("runtime.plan_scaffold.upsert_plan_entry", side_effect=PlanRegistryError("boom")): + with mock.patch("runtime._planning.upsert_plan_entry", side_effect=PlanRegistryError("boom")): result = run_runtime( "~go plan 实现 runtime skeleton", workspace_root=workspace, @@ -223,9 +216,9 @@ def test_inspect_plan_registry_exposes_recommendations_without_switching_current store = StateStore(config) store.ensure() - current_plan = create_plan_scaffold("第一性原理协作规则分层落地", config=config, level="standard") + current_plan = _scaffold_with_registry("第一性原理协作规则分层落地", config=config, level="standard") store.set_current_plan(current_plan) - backlog_plan = create_plan_scaffold("补 runtime 骨架", config=config, level="standard") + backlog_plan = _scaffold_with_registry("补 runtime 骨架", config=config, level="standard") payload = inspect_plan_registry(config=config, plan_id=backlog_plan.plan_id) @@ -243,7 +236,7 @@ def test_archive_receipt_includes_knowledge_sync_result(self) -> None: store = StateStore(config) store.ensure() - artifact = create_plan_scaffold("test sync audit", config=config, level="standard") + artifact = _scaffold_with_registry("test sync audit", config=config, level="standard") store.set_current_plan(artifact) result = apply_archive_subject( @@ -279,7 +272,7 @@ def test_archive_blocked_by_knowledge_sync_preserves_audit_trail(self) -> None: store = StateStore(config) store.ensure() - artifact = create_plan_scaffold("test blocked audit", config=config, level="full") + artifact = _scaffold_with_registry("test blocked audit", config=config, level="full") store.set_current_plan(artifact) result = apply_archive_subject( diff --git a/tests/test_runtime_plan_scaffold.py b/tests/test_runtime_plan_scaffold.py index bf847f7..01abbf8 100644 --- a/tests/test_runtime_plan_scaffold.py +++ b/tests/test_runtime_plan_scaffold.py @@ -73,13 +73,3 @@ def test_plan_scaffold_persists_topic_key(self) -> None: self.assertEqual(artifact.topic_key, "runtime") self.assertIn("feature_key: runtime", tasks_text) - - def test_explicit_new_plan_patterns_ignore_ambiguous_other_plan_phrase(self) -> None: - self.assertFalse(request_explicitly_wants_new_plan("分析这个方案和其他 plan 的差异")) - self.assertTrue(request_explicitly_wants_new_plan("请新建一个 plan 处理这个问题")) - - def test_explicit_new_plan_patterns_respect_local_negation_without_global_blocking(self) -> None: - self.assertFalse(request_explicitly_wants_new_plan("不要新建新的 plan 包,直接在当前 plan 上继续细化")) - self.assertTrue(request_explicitly_wants_new_plan("不要复用当前 plan,直接新建 plan")) - self.assertTrue(request_explicitly_wants_new_plan("不是不要新建 plan,而是要新建 plan")) - self.assertTrue(request_explicitly_wants_new_plan("do not create a new plan; create a new plan now"))