diff --git a/ITERATION_LOG.md b/ITERATION_LOG.md index 9ac47c1..151300e 100644 --- a/ITERATION_LOG.md +++ b/ITERATION_LOG.md @@ -16,6 +16,22 @@ --- +## 2026-08-16:中文 owner 范围收拢与外部 Windows 案例收口 + +### FLG-ITER-20260816-01:中文 owner 范围收拢在短句与长口述中重复漏提取 [confirmed — P0] + +**场景**:2026-08-11,FlightModeAI owner 用长段中文口述把产品收拢为飞行学习包,说明飞行前选主题、飞行中完成、落地后分享的闭环,也指出端侧模型体验与互动方式仍待验证。`closeout` 只触发 lessons,正式候选为 0。2026-08-16,owner 又用一句“我们主要做 Canvas Prompt FlowGrid 其他我觉得意义不大”收拢资源范围,`closeout` 再次返回空候选。 + +**发现**:两次样本长度不同,暴露的是同一缺陷。中文 owner 通过“收拢、定义为、主要做、其他意义不大”等自然口语表达范围取舍时,现有入口和提取器不能稳定保存来源明确的候选。[confirmed] + +**影响**:安全 review gate 阻止了错误自动写入,但 owner 已经明确的范围可能静默丢失。宿主随后只能人工使用 `decision add` 或 confirmed capture 补账,正式决策还可能夹带无证据的模板字段。 + +**处理方向**:一句带明确 owner 来源的短判断走 source-backed capture 草稿;会议、长讨论和包含范围、闭环、体验证据、待验证问题的口述走 `closeout`。长口述只有在保留 `User:` 归因、范围收拢信号和流程信号时生成 pending candidate;普通项目名列表 abstain。正式决策只写真实存在的字段,继续保留 review 与来源门。 + +**本地候选验证**:新增 8 月 11 日真实长口述 fixture、8 月 16 日短 capture 回归和无来源实体列表反例。外部 Windows + WorkBuddy 案例另建隐私安全的冷启动输入预检,普通项目文件提供 1/6 个连续性不变量,FlowGrid Context Pack 提供 6/6;该结果只度量输入信息,不证明 Agent 输出更好、用户满意或跨会话增益。 + +--- + ## 2026-08-08:主线优化审计与合并收口 **场景**:项目主编排同时核对映射 runtime、Vault 治理层、遗留开发 worktree、`flg status`、`flg capture list`、`doctor --strict` 和全量测试。 diff --git a/README.md b/README.md index 5fc3b44..cadd550 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ [![CI](https://github.com/dlxeva/FlowGrid/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dlxeva/FlowGrid/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/python-%3E%3D3.10-3776AB?logo=python&logoColor=white) [![License](https://img.shields.io/github/license/dlxeva/FlowGrid)](./LICENSE) -[![AML Academic Textual #8](https://img.shields.io/badge/AML%20Academic%20Textual-%238-7C3AED)](https://agentmemories.ai/leaderboard/academic/textual) FlowGrid helps business-project knowledge workers turn messy AI work sessions into state-aware, traceable, and resumable project context. @@ -19,28 +18,6 @@ FlowGrid helps business-project knowledge workers turn messy AI work sessions in It is built for long-running work where the deliverable is not just a document, but a defensible judgment chain: why this proposal makes sense, why this direction was chosen, what alternatives were rejected, and when a past judgment should be revised. -## Public Benchmark Result - -The independent [FlowGrid AML Retriever](https://github.com/dlxeva/flowgrid-aml-retriever) -ranked **#8** in the first public Agent Memory Leaderboard Academic Textual -track, with an overall score of **43.98**, **1.08 points behind the top-ranked -entry**. [View the public leaderboard →](https://agentmemories.ai/leaderboard/academic/textual) - -The competition entry and FlowGrid Core share ideas about evidence provenance, -temporal state, conflict preservation, and traceable retrieval, but they are -different systems: - -- **AML Retriever** is a benchmark-specific, deterministic Add/Search memory - retriever. It stores messages, builds retrieval views, and returns ranked - evidence under the competition contract. -- **FlowGrid Core** is a local project-continuity layer. It preserves reviewed - judgments, constraints, rejected paths, current actions, and handoff state for - long-running human-AI projects. - -The ranking validates the competition retriever under AML's unified evaluation. -It does not establish FlowGrid Core's overall product quality, user adoption, or -universal superiority over other memory systems. - ## How It Carries a Project Forward ![FlowGrid project continuation lifecycle](./docs/assets/flowgrid-lifecycle.svg) @@ -136,6 +113,11 @@ flg context --mode manifest flg handoff ``` +Hosts should route one short, explicitly attributed owner judgment to a +source-backed capture draft, for example `flg capture add`. A meeting, long +discussion, or multi-signal product explanation goes through `flg closeout`. +An entity list with no attributed trade-off produces no decision candidate. + After that, you will have: - a local project ledger: `PROJECT.md`, `FRAMING.md`, `DECISIONS.md`, `SNAPSHOT.md`, `PROGRESS.md` @@ -152,6 +134,22 @@ remains pending. You can run that flow from Codex, Hermes, OpenClaw, Claude, or any AI agent work product that can read files and run commands. +## Public Benchmark Result + +[![AML Academic Textual #8](https://img.shields.io/badge/AML%20Academic%20Textual-%238-7C3AED)](https://agentmemories.ai/leaderboard/academic/textual) + +The independent [FlowGrid AML Retriever](https://github.com/dlxeva/flowgrid-aml-retriever) +ranked **#8** in the first public Agent Memory Leaderboard Academic Textual +track, with an overall score of **43.98**, **1.08 points behind the top-ranked +entry**. [View the public leaderboard →](https://agentmemories.ai/leaderboard/academic/textual) + +The competition entry and FlowGrid Core share ideas about evidence provenance, +temporal state, conflict preservation, and traceable retrieval, but they are +different systems. AML Retriever is a benchmark-specific deterministic +Add/Search system. FlowGrid Core is the local continuity product described +above. The ranking does not establish Core product quality, user adoption, or +universal superiority. + ## Independent Runtime Experiments FlowGrid's local-first CLI and protocol are the main product. Three independent diff --git a/README.zh-CN.md b/README.zh-CN.md index 54e14b9..843b57e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,6 @@ [![CI](https://github.com/dlxeva/FlowGrid/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dlxeva/FlowGrid/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/python-%3E%3D3.10-3776AB?logo=python&logoColor=white) [![License](https://img.shields.io/github/license/dlxeva/FlowGrid)](./LICENSE) -[![AML 学术文本榜 #8](https://img.shields.io/badge/AML%20Academic%20Textual-%238-7C3AED)](https://agentmemories.ai/leaderboard/academic/textual) FlowGrid 帮助业务项目型知识工作者,把混乱的 AI 协作过程转成有状态边界、可追溯、可恢复的项目上下文。 @@ -19,22 +18,6 @@ FlowGrid 帮助业务项目型知识工作者,把混乱的 AI 协作过程转 它适合长期推进的模糊项目。此类项目的交付物通常不只是一份文档,还包括一条能被解释和质询的判断链:为什么这个方案成立,为什么选择这个方向,哪些备选被放弃,什么情况下旧判断需要修正。 -## 公开评测成绩 - -独立参赛项目 [FlowGrid AML Retriever](https://github.com/dlxeva/flowgrid-aml-retriever) -在 Agent Memory Leaderboard 首期公开评测的学术方法文本记忆榜中位列 -**第 8 名**,综合分 **43.98**,与榜首相差 **1.08 分**。 -[查看公开榜单 →](https://agentmemories.ai/leaderboard/academic/textual) - -参赛 Retriever 与 FlowGrid Core 共享证据来源、时间状态、冲突保留和可追溯检索等思想, -但两者不是同一个系统: - -- **AML Retriever** 是针对统一评测契约构建的确定性 Add/Search 记忆检索器,负责保存消息、构建检索视图并返回排序后的原始证据。 -- **FlowGrid Core** 是本地项目连续性层,负责保存经过审核的判断、约束、被否决路径、当前行动和长期人机项目的交接状态。 - -这项成绩验证的是参赛 Retriever 在 AML 统一环境中的表现,不等同于 FlowGrid Core -整体产品能力、用户采用情况,也不能证明它普遍优于其他记忆系统。 - ## 一张图看懂如何续接项目 ![FlowGrid 项目续接生命周期](./docs/assets/flowgrid-lifecycle.svg) @@ -112,6 +95,10 @@ flg context --mode resume flg handoff ``` +宿主应把一句带明确 owner 来源的短判断送入有来源的 capture 草稿,例如 +`flg capture add`。会议、长讨论或包含多个产品信号的口述走 `flg closeout`。 +只有项目名、没有来源归因和取舍关系的列表不生成决策候选。 + 跑完以后,你会得到: - 一套本地项目账本:`PROJECT.md`、`FRAMING.md`、`DECISIONS.md`、`SNAPSHOT.md`、`PROGRESS.md` @@ -124,6 +111,19 @@ flg handoff 这条流程可以运行在 Codex、Claude、OpenClaw、Hermes 或其他 AI agent work 产品里,不要求用户改用新的工作台。 +## 公开评测成绩 + +[![AML 学术文本榜 #8](https://img.shields.io/badge/AML%20Academic%20Textual-%238-7C3AED)](https://agentmemories.ai/leaderboard/academic/textual) + +独立参赛项目 [FlowGrid AML Retriever](https://github.com/dlxeva/flowgrid-aml-retriever) +在 Agent Memory Leaderboard 首期公开评测的学术方法文本记忆榜中位列 +**第 8 名**,综合分 **43.98**,与榜首相差 **1.08 分**。 +[查看公开榜单 →](https://agentmemories.ai/leaderboard/academic/textual) + +参赛 Retriever 与 FlowGrid Core 共享证据来源、时间状态、冲突保留和可追溯检索等思想。 +AML Retriever 是面向榜单契约的确定性 Add/Search 系统,FlowGrid Core 是上文描述的 +本地项目连续性产品。这项成绩不构成 Core 产品质量、用户采用或普遍优越性的证明。 + ## 独立运行时实验 FlowGrid 的主产品仍是本地优先 CLI 与项目协议。三个独立仓库在受控运行环境或评测契约下验证部分思路: diff --git a/evals/README.md b/evals/README.md index 765ef71..cc1f273 100644 --- a/evals/README.md +++ b/evals/README.md @@ -7,6 +7,20 @@ scenario; Context Pack is not assumed to outperform a clean raw history. This directory contains scenario fixtures for evaluating that claim. +## External cold-start fixture preflight + +The privacy-safe Windows + WorkBuddy fixture compares which known continuity +invariants are available in ordinary project files and a FlowGrid Context Pack: + +```bash +python evals/cold_start_ab.py --output-dir evals/results +``` + +The deterministic result is recorded in +[`results/external-windows-workbuddy-cold-start.md`](results/external-windows-workbuddy-cold-start.md). +It measures input information only. A fresh-agent output A/B remains required +before claiming a continuation gain. + Real-project, dogfood, customer-field, and host-acceptance evidence is indexed separately in [`case-registry.json`](case-registry.json). The registry records both the supported claim and limitation of every case; it must not label diff --git a/evals/cold-start/external-windows-workbuddy/case.md b/evals/cold-start/external-windows-workbuddy/case.md new file mode 100644 index 0000000..35eccf7 --- /dev/null +++ b/evals/cold-start/external-windows-workbuddy/case.md @@ -0,0 +1,19 @@ +# External Windows + WorkBuddy cold-start fixture + +This fixture is a privacy-safe reconstruction of behavior visible in screenshots +from one external Windows + WorkBuddy use of FlowGrid. It is not a transcript, +testimonial, satisfaction claim, or cross-session result. + +Observed behavior boundary: + +- an external user installed and used FlowGrid on a real project; +- the agent used project framing, constraints, and decisions to resist a + physical-gardening/e-commerce interpretation of a mobile-game CSR project; +- D-001 through D-003 retained decision rationale and rejected paths while a + deck changed across three revisions; +- DAU, budget, average order value, and contracting entity remained unknown; +- Windows path handling caused friction; +- the screenshots came from one agent with its existing chat context. + +The fixture tests only whether a cold-start input exposes the known continuity +invariants. It does not test model quality by itself. diff --git a/evals/cold-start/external-windows-workbuddy/flg-context-pack.md b/evals/cold-start/external-windows-workbuddy/flg-context-pack.md new file mode 100644 index 0000000..7019d02 --- /dev/null +++ b/evals/cold-start/external-windows-workbuddy/flg-context-pack.md @@ -0,0 +1,31 @@ +# FlowGrid Context Pack excerpt + +## Current scope + +The project is a CSR marketing campaign for a mobile game. It is not a +physical gardening, e-commerce, or physical-fulfillment project. + +## Reviewed decisions + +- D-001: Keep the campaign inside the mobile-game CSR scope. Reject physical + gardening and physical fulfillment because they change the product and + delivery object. +- D-002: Keep the channel brief aligned with the approved CSR mechanism while + the presentation deck changes visual form. A visual revision does not reopen + the project mechanism. +- D-003: Do not invent DAU, budget, average order value, or the contracting + entity. Keep each item as an owner-confirmation question until evidence is + supplied. + +## Rejected paths + +- physical gardening +- e-commerce +- physical fulfillment + +## Unknowns + +- DAU +- budget +- average order value +- contracting entity diff --git a/evals/cold-start/external-windows-workbuddy/invariants.json b/evals/cold-start/external-windows-workbuddy/invariants.json new file mode 100644 index 0000000..027560e --- /dev/null +++ b/evals/cold-start/external-windows-workbuddy/invariants.json @@ -0,0 +1,15 @@ +{ + "case_id": "external-windows-workbuddy-cold-start-v1", + "modes": { + "no_flg": "no-flg-project-files.md", + "flg": "flg-context-pack.md" + }, + "invariants": [ + {"id": "scope-mobile-game-csr", "needles": ["mobile game", "CSR"]}, + {"id": "reject-physical-gardening", "needles": ["reject", "physical gardening"]}, + {"id": "reject-ecommerce", "needles": ["e-commerce", "rejected paths"]}, + {"id": "preserve-d001-d003", "needles": ["D-001", "D-002", "D-003"]}, + {"id": "do-not-invent-owner-facts", "needles": ["Do not invent", "DAU", "budget", "average order value", "contracting entity"]}, + {"id": "visual-revision-does-not-reopen", "needles": ["visual revision", "does not reopen"]} + ] +} diff --git a/evals/cold-start/external-windows-workbuddy/no-flg-project-files.md b/evals/cold-start/external-windows-workbuddy/no-flg-project-files.md new file mode 100644 index 0000000..1faa874 --- /dev/null +++ b/evals/cold-start/external-windows-workbuddy/no-flg-project-files.md @@ -0,0 +1,6 @@ +# Ordinary project files + +The project is a CSR marketing campaign for a mobile game. A presentation deck +has gone through three visual revisions. The next deliverable is a channel +brief. DAU, budget, average order value, and the contracting entity have not +been supplied in these files. diff --git a/evals/cold-start/external-windows-workbuddy/task.md b/evals/cold-start/external-windows-workbuddy/task.md new file mode 100644 index 0000000..8710fdc --- /dev/null +++ b/evals/cold-start/external-windows-workbuddy/task.md @@ -0,0 +1,6 @@ +# Cold-start task + +Review the incoming request to turn the project into a physical gardening and +e-commerce campaign. Produce a channel brief that preserves confirmed project +scope, does not revive rejected paths, and lists facts that still require owner +confirmation. diff --git a/evals/cold_start_ab.py b/evals/cold_start_ab.py new file mode 100644 index 0000000..72b0faa --- /dev/null +++ b/evals/cold_start_ab.py @@ -0,0 +1,97 @@ +"""Deterministic preflight for the external-case cold-start A/B fixture.""" + +from __future__ import annotations + +import argparse +import json +from datetime import date +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +DEFAULT_CASE = ROOT / "cold-start" / "external-windows-workbuddy" + + +def evaluate(case_dir: Path) -> dict: + config = json.loads((case_dir / "invariants.json").read_text(encoding="utf-8")) + results = {} + for mode, filename in config["modes"].items(): + content = (case_dir / filename).read_text(encoding="utf-8").lower() + checks = [] + for invariant in config["invariants"]: + passed = all(needle.lower() in content for needle in invariant["needles"]) + checks.append({"id": invariant["id"], "passed": passed}) + results[mode] = { + "passed": sum(item["passed"] for item in checks), + "total": len(checks), + "checks": checks, + } + return { + "case_id": config["case_id"], + "method": "deterministic input-invariant preflight", + "results": results, + "boundary": ( + "Measures information available to a cold-start agent. Does not measure " + "agent output quality, user satisfaction, or cross-session improvement." + ), + } + + +def render_markdown(report: dict) -> str: + no_flg = report["results"]["no_flg"] + flg = report["results"]["flg"] + lines = [ + "# External Windows + WorkBuddy cold-start A/B preflight", + "", + f"Date: {date.today().isoformat()}", + "", + "## Result", + "", + f"- Ordinary project files: {no_flg['passed']}/{no_flg['total']} continuity invariants available", + f"- FlowGrid Context Pack: {flg['passed']}/{flg['total']} continuity invariants available", + "", + "| Invariant | Ordinary files | FlowGrid pack |", + "| --- | ---: | ---: |", + ] + no_flg_checks = {item["id"]: item["passed"] for item in no_flg["checks"]} + flg_checks = {item["id"]: item["passed"] for item in flg["checks"]} + for invariant_id in no_flg_checks: + lines.append( + f"| `{invariant_id}` | {'pass' if no_flg_checks[invariant_id] else 'missing'} " + f"| {'pass' if flg_checks[invariant_id] else 'missing'} |" + ) + lines.extend( + [ + "", + "## Interpretation boundary", + "", + report["boundary"], + "", + "The observed screenshots establish real external use and behavior inside one existing chat context. " + "A fresh-agent output comparison is still required before claiming a continuation gain.", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--case-dir", type=Path, default=DEFAULT_CASE) + parser.add_argument("--output-dir", type=Path) + args = parser.parse_args() + report = evaluate(args.case_dir) + print(json.dumps(report, ensure_ascii=False, indent=2)) + if args.output_dir: + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / "external-windows-workbuddy-cold-start.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + (args.output_dir / "external-windows-workbuddy-cold-start.md").write_text( + render_markdown(report), encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/evals/results/external-windows-workbuddy-cold-start.json b/evals/results/external-windows-workbuddy-cold-start.json new file mode 100644 index 0000000..7c55d75 --- /dev/null +++ b/evals/results/external-windows-workbuddy-cold-start.json @@ -0,0 +1,31 @@ +{ + "case_id": "external-windows-workbuddy-cold-start-v1", + "method": "deterministic input-invariant preflight", + "results": { + "no_flg": { + "passed": 1, + "total": 6, + "checks": [ + {"id": "scope-mobile-game-csr", "passed": true}, + {"id": "reject-physical-gardening", "passed": false}, + {"id": "reject-ecommerce", "passed": false}, + {"id": "preserve-d001-d003", "passed": false}, + {"id": "do-not-invent-owner-facts", "passed": false}, + {"id": "visual-revision-does-not-reopen", "passed": false} + ] + }, + "flg": { + "passed": 6, + "total": 6, + "checks": [ + {"id": "scope-mobile-game-csr", "passed": true}, + {"id": "reject-physical-gardening", "passed": true}, + {"id": "reject-ecommerce", "passed": true}, + {"id": "preserve-d001-d003", "passed": true}, + {"id": "do-not-invent-owner-facts", "passed": true}, + {"id": "visual-revision-does-not-reopen", "passed": true} + ] + } + }, + "boundary": "Measures information available to a cold-start agent. Does not measure agent output quality, user satisfaction, or cross-session improvement." +} diff --git a/evals/results/external-windows-workbuddy-cold-start.md b/evals/results/external-windows-workbuddy-cold-start.md new file mode 100644 index 0000000..a6e7684 --- /dev/null +++ b/evals/results/external-windows-workbuddy-cold-start.md @@ -0,0 +1,23 @@ +# External Windows + WorkBuddy cold-start A/B preflight + +Date: 2026-08-16 + +## Result + +- Ordinary project files: 1/6 continuity invariants available +- FlowGrid Context Pack: 6/6 continuity invariants available + +| Invariant | Ordinary files | FlowGrid pack | +| --- | ---: | ---: | +| `scope-mobile-game-csr` | pass | pass | +| `reject-physical-gardening` | missing | pass | +| `reject-ecommerce` | missing | pass | +| `preserve-d001-d003` | missing | pass | +| `do-not-invent-owner-facts` | missing | pass | +| `visual-revision-does-not-reopen` | missing | pass | + +## Interpretation boundary + +Measures information available to a cold-start agent. Does not measure agent output quality, user satisfaction, or cross-session improvement. + +The observed screenshots establish real external use and behavior inside one existing chat context. A fresh-agent output comparison is still required before claiming a continuation gain. diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index c6f4957..3dbb4e4 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -48,6 +48,17 @@ def run_cmd( def resolve_flg_command() -> tuple[list[str], dict[str, str] | None, str]: """Use the current repo build, not an unrelated global installation.""" + if os.environ.get("FLOWGRID_SMOKE_SOURCE") == "1": + existing_pythonpath = os.environ.get("PYTHONPATH", "") + pythonpath = os.pathsep.join( + part for part in (str(REPO_ROOT / "src"), existing_pythonpath) if part + ) + return ( + [sys.executable, "-m", "flg.cli"], + {"PYTHONPATH": pythonpath}, + "forced repository source tree", + ) + exe_name = "flg.exe" if sys.platform.startswith("win") else "flg" repo_venv_exe = REPO_ROOT / ".venv" / ("Scripts" if sys.platform.startswith("win") else "bin") / exe_name if repo_venv_exe.exists(): diff --git a/src/flg/commands/capture.py b/src/flg/commands/capture.py index bf95f55..4aa6c09 100644 --- a/src/flg/commands/capture.py +++ b/src/flg/commands/capture.py @@ -463,51 +463,37 @@ def _build_decision_entry(number: int, meta: dict) -> str: language = project_language(Path.cwd()) claim = meta.get("claim", "") rationale = meta.get("rationale", "") - question = meta.get("question") or ("Key judgment in the current project" if language == "en" else "项目推进中的关键判断") + question = meta.get("question") alternatives = meta.get("alternatives", []) - alt_str = "、".join(alternatives) if alternatives else ("No alternatives recorded" if language == "en" else "未记录备选方案") - risks = meta.get("risks") or ("To be completed from project context" if language == "en" else "待结合项目上下文补充") + risks = meta.get("risks") evidence = meta.get("raw_evidence", "") + capture_id = meta.get("id", "unknown") + reviewed_at = meta.get("reviewed_at", "unknown") - return f"""## D-{number:03d} | {claim[:50]} + sections = [f"""## D-{number:03d} | {claim} ### 决策时间 {today} -### 所属阶段 -执行 - -### 决策背景 -由 `flg capture review` 从候选判断中确认写入。 - -### 核心问题 -{question} - -### 备选方案 -A. {alt_str} +### 决策状态 +confirmed ### 最终决策 {claim} +"""] + if question and question not in {"(not specified)", "Not provided", "未提供"}: + sections.append(f"### 核心问题\n{question}\n") + if rationale: + sections.append(f"### 决策理由\n{rationale}\n") + if alternatives: + sections.append(f"### 备选方案\n" + "\n".join(f"- {item}" for item in alternatives) + "\n") + if risks: + sections.append(f"### 风险判断\n{risks}\n") + sections.append(f"""--- -### 决策理由 -{rationale} - -### 放弃理由 -选择了当前方案,放弃其他备选方案。 - -### 风险判断 -{risks} - -### 后续验证 -通过后续执行结果和项目反馈验证。 - -### 复盘入口 -如果关键前提变化或出现新的替代方案,需要重新评估。 - ---- - -*Source: {evidence if evidence else ('user judgment' if language == "en" else '用户判断')}* -""" +*Source: capture_review: .flg/captures/{capture_id}.md; reviewed_at: {reviewed_at}; user_text: {evidence if evidence else ('user judgment' if language == 'en' else '用户判断')}* +""") + return "\n".join(sections) def _load_evidence_index(root: Path) -> dict: @@ -600,6 +586,7 @@ def capture_review( if choice == "a": decision_id = f"D-{next_num:03d}" + meta["reviewed_at"] = reviewed_at entry = _build_decision_entry(next_num, meta) entry = localize_ledger_entry(entry, project_language(root)) decisions_content = decisions_content.rstrip() + "\n\n" + entry @@ -616,7 +603,6 @@ def capture_review( "rejected_alternatives": ", ".join(meta.get("alternatives", [])), } meta["status"] = "confirmed" - meta["reviewed_at"] = reviewed_at _write_frontmatter(filepath, meta) accepted.append((capture_id, decision_id)) next_num += 1 diff --git a/src/flg/commands/closeout.py b/src/flg/commands/closeout.py index 4a4978c..e59d25c 100644 --- a/src/flg/commands/closeout.py +++ b/src/flg/commands/closeout.py @@ -281,7 +281,7 @@ def iter_segments(content: str) -> list[str]: re.IGNORECASE, ) inline_actor = re.compile( - r"^\s*(?:user|human|client|customer|assistant|agent|ai|用户|客户|甲方|助手|系统)\s*[::]", + r"^\s*(user|human|client|customer|assistant|agent|ai|用户|客户|甲方|助手|系统)\s*[::]", re.IGNORECASE, ) for line in content.splitlines(): @@ -293,7 +293,10 @@ def iter_segments(content: str) -> list[str]: segment = match.strip() if not segment: continue - if current_actor and not inline_actor.match(segment): + inline_match = inline_actor.match(segment) + if inline_match: + current_actor = inline_match.group(1) + elif current_actor: segment = f"{current_actor}: {segment}" segments.append(segment) return segments @@ -374,6 +377,34 @@ def is_user_directive(segment: str) -> bool: return bool(match_pattern(utterance, list(directive_patterns))) +def is_long_user_scope_narrowing(segment: str) -> bool: + """Recognize an attributed, discussion-length owner scope contraction. + + Short owner statements belong in the real-time capture path. Closeout + handles the longer case where the owner explains a product boundary, + operating loop, and unresolved experience questions in one utterance. + Requiring an explicit human label keeps entity lists and agent summaries + from becoming candidate decisions. + """ + if source_actor_for_segment(segment) != "user": + return False + utterance = _segment_utterance(segment).strip() + if len(utterance) < 120 or "?" in utterance or "?" in utterance: + return False + scope_signal = match_pattern( + utterance, + [ + r"(?:收拢|收窄|聚焦).{0,80}(?:定义为|只做|主要做|就是)", + r"(?:功能|范围|产品).{0,30}(?:太泛|过于宽泛).{0,80}(?:定义为|收拢|收窄)", + ], + ) + loop_signal = match_pattern( + utterance, + [r"(?:整个|完整).{0,12}(?:流程|循环|闭环)", r"(?:之前|过程中|落地后|结束后).{0,80}(?:然后|之后)"], + ) + return bool(scope_signal and loop_signal) + + def confirmed_assistant_scope(segments: list[str], confirmation_index: int) -> str | None: """Bind a short user confirmation only to one unambiguous assistant proposal.""" assistant_block: list[str] = [] @@ -567,6 +598,27 @@ def extract_decisions( }) continue + owner_block = sentence + if source_actor_for_segment(sentence) == "user": + block_end = index + 1 + while block_end < len(clean_segments) and source_actor_for_segment(clean_segments[block_end]) == "user": + block_end += 1 + owner_block = " ".join(clean_segments[index:block_end]) + if is_long_user_scope_narrowing(owner_block): + original_block = " ".join(aligned_original[index:index + (block_end - index)]) + ctx = _get_context_window(clean_content, sentence, all_segments=clean_segments) + context_info = extract_decision_context(ctx) + decisions.append({ + "content": original_block, + "type": "owner_scope_narrowing", + "confidence": "high", + "keyword": "attributed long-form scope narrowing", + "reasoning": "; ".join(context_info["reasoning"]), + "rejected_alternatives": "; ".join(context_info["rejected_alternatives"]), + "reversal_conditions": "; ".join(context_info["reversal_conditions"]), + }) + continue + # match_text equals sentence here since clean_content already stripped code. # (Kept as a variable for readability and future hooks.) match_text = sentence @@ -964,6 +1016,10 @@ def extract_lessons_learned_signals( r"(?:conversion|conversion path) (?:is|was) weak\b", r"(?:did not|does not) (?:prove|explain) (?:conversion|the conversion path)\b", r"surface visibility\b", + # Chinese spoken owner reasoning and negative experience evidence. + r"(?:功能|范围|产品).{0,30}(?:太泛|过于宽泛)", + r"(?:体验|效果).{0,30}(?:不好|不够好|不理想)", + r"没有.{0,20}(?:学到|获得感|完成感)", ] # Rejection patterns - what alternatives were rejected @@ -1081,6 +1137,8 @@ def why_this_is_a_decision(decision: dict) -> str: return f"Attributed user confirmation of one assistant proposal: '{decision['keyword']}'" elif decision["type"] == "user_directive": return f"Attributed imperative user rule detected: '{decision['keyword']}'" + elif decision["type"] == "owner_scope_narrowing": + return "Attributed long-form owner scope narrowing with an operating-loop signal" return "Decision keyword detected" diff --git a/src/flg/commands/decision_cmd.py b/src/flg/commands/decision_cmd.py index 0721aac..dfd16b3 100644 --- a/src/flg/commands/decision_cmd.py +++ b/src/flg/commands/decision_cmd.py @@ -130,8 +130,6 @@ def decision_add( if alternatives else [] ) - missing = "Not provided" if language == "en" else "未提供" - alt_str = "、".join(alt_list) if alt_list else missing source_type = "user_confirmation" if evidence else "direct_command" authority = "high" if evidence else "medium" source_label = ( @@ -144,51 +142,31 @@ def decision_add( ) ) relation_count = sum(len(targets) for targets in relations.values()) - relation_section = ( - format_relation_section(relations, language) + "\n\n" - if relation_count - else "" - ) - - entry = f"""## {decision_id} | {decision[:50]} + sections = [f"""## {decision_id} | {decision} ### 决策时间 {today} -### 所属阶段 -执行 - -### 决策背景 -由 `flg decision add` 直接写入决策日志。 - -### 核心问题 -{question or ('Key judgment in the current project' if language == "en" else '项目推进中的关键判断')} - -### 备选方案 -A. {alt_str} +### 决策状态 +confirmed ### 最终决策 {decision} - -{relation_section}### 决策理由 -{rationale} - -### 放弃理由 -{missing} - -### 风险判断 -{risks or missing} - -### 后续验证 -{missing} - -### 复盘入口 -{missing} - ---- +"""] + if question: + sections.append(f"### 核心问题\n{question}\n") + if relation_count: + sections.append(format_relation_section(relations, language) + "\n") + sections.append(f"### 决策理由\n{rationale}\n") + if alt_list: + sections.append("### 备选方案\nA. " + "、".join(alt_list) + "\n") + if risks: + sections.append(f"### 风险判断\n{risks}\n") + sections.append(f"""--- *{type_label} | Source: {source_label}* -""" +""") + entry = "\n".join(sections) entry = localize_ledger_entry(entry, language) decisions_content = decisions_content.rstrip() + "\n\n" + entry diff --git a/src/flg/commands/session.py b/src/flg/commands/session.py index 22d97be..acd09ba 100644 --- a/src/flg/commands/session.py +++ b/src/flg/commands/session.py @@ -10,7 +10,7 @@ import typer from rich.console import Console -from ..core.files import is_flg_project +from ..core.files import is_flg_project, normalize_user_path console = Console() @@ -33,7 +33,7 @@ def archive_session( force: bool = False, ) -> Path: """Copy raw session evidence into the project's sessions directory.""" - source = source_file.expanduser().resolve() + source = normalize_user_path(source_file).resolve() if not source.exists() or not source.is_file(): raise FileNotFoundError(f"Session file not found: {source}") sessions_dir = (root / ".flg" / "sessions").resolve() @@ -78,7 +78,7 @@ def save_session( console.print("[dim]Choose another --name or pass --force to replace it.[/dim]") raise typer.Exit(1) from exc - if target == Path(source_file).expanduser().resolve(): + if target == normalize_user_path(source_file).resolve(): console.print(f"[green]Session already archived:[/green] {target}") else: console.print(f"[green]Archived session:[/green] {target}") diff --git a/src/flg/core/evidence.py b/src/flg/core/evidence.py index 622ef24..009da51 100644 --- a/src/flg/core/evidence.py +++ b/src/flg/core/evidence.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import re from hashlib import sha256 from datetime import datetime @@ -137,7 +138,7 @@ def normalize_decision_status(value: str) -> str: def _section(block: str, headings: tuple[str, ...]) -> str: heading_pattern = "|".join(re.escape(heading) for heading in headings) match = re.search( - rf"^###\s+(?:{heading_pattern})\s*$\n([\s\S]*?)(?=^###\s|^##\s|\Z)", + rf"^###\s+(?:{heading_pattern})\s*$\n([\s\S]*?)(?=^###\s|^##\s|^---\s*$|\Z)", block, re.MULTILINE, ) @@ -186,6 +187,9 @@ def parse_decisions_ledger(content: str) -> list[dict[str, str]]: source_match = re.search(r"^\*.*?\|\s*Source:\s*(.*?)\*\s*$", block, re.MULTILINE) source = source_match.group(1).strip() if source_match else _section(block, ("证据来源", "Evidence")) + source_capture_match = re.search(r"(?:^|\s)(\.flg/captures/[^;\s*]+)", source) + source_excerpt_match = re.search(r"(?:^|;\s*)user_text:\s*(.+)$", source) + reviewed_at_match = re.search(r"(?:^|;\s*)reviewed_at:\s*([^;]+)", source) if "用户明确指令" in source or "用户原话" in source or "user_confirmation" in source: source_type = "user_confirmation" elif "直接写入命令" in source or "direct command" in source.lower(): @@ -209,6 +213,9 @@ def parse_decisions_ledger(content: str) -> list[dict[str, str]]: "status": status, "source_type": source_type, "source": source, + "source_capture": source_capture_match.group(1) if source_capture_match else "", + "source_excerpt": source_excerpt_match.group(1).strip() if source_excerpt_match else "", + "reviewed_at": reviewed_at_match.group(1).strip() if reviewed_at_match else "", } ) return decisions @@ -274,7 +281,11 @@ def rebuild_evidence_index(root: Path) -> dict[str, Any]: "medium" if decision["source_type"] == "direct_command" else "high", ), "source_type": old.get("source_type") if same_provenance and old.get("source_type") else decision["source_type"], - "source_excerpt": old.get("source_excerpt") if same_provenance and old.get("source_excerpt") else decision["what_decided"], + "source_excerpt": ( + old.get("source_excerpt") + if same_provenance and old.get("source_excerpt") + else decision.get("source_excerpt") or decision["what_decided"] + ), "title": decision["title"], "rationale": decision["rationale"], "alternatives": decision["alternatives"], @@ -285,6 +296,10 @@ def rebuild_evidence_index(root: Path) -> dict[str, Any]: for field in _PROVENANCE_FIELDS: if old.get(field): item[field] = old[field] + elif decision.get("source_capture"): + item["source_capture"] = decision["source_capture"] + if not item.get("reviewed_at") and decision.get("reviewed_at"): + item["reviewed_at"] = decision["reviewed_at"] items[decision_id] = item return enrich_source_episodes(root, { @@ -296,7 +311,13 @@ def rebuild_evidence_index(root: Path) -> dict[str, Any]: def _path_exists(root: Path, value: str) -> bool: - path = Path(value) + from .files import normalize_user_path + + path = normalize_user_path(value) + # A drive-qualified path is absolute on Windows but looks relative to + # pathlib on POSIX. Never join it to the project root on a foreign host. + if os.name != "nt" and re.match(r"^[A-Za-z]:[\\/]", value): + return False return path.exists() if path.is_absolute() else (root / path).exists() @@ -340,7 +361,14 @@ def validate_project(root: Path) -> dict[str, Any]: state_path = root / ".flg" / "state.json" state_text = state_path.read_text(encoding="utf-8") if state_path.exists() else "" - legacy_paths = sorted(set(re.findall(r"(?:/mnt/c/|/root/|[A-Za-z]:\\)[^\"\n]*", state_text))) + legacy_paths = sorted( + set( + re.findall( + r"(?:/mnt/[A-Za-z]/|(? Path: return Path.cwd() +def normalize_user_path(value: str | Path, *, windows: bool | None = None) -> Path: + """Normalize CLI path spellings without changing stored evidence text. + + Native Windows accepts both ``C:\\...`` and ``C:/...``. Git Bash commonly + passes the same location as ``/c/...``; convert only that drive-root form + when running on Windows. POSIX hosts retain the original spelling so a + foreign path is reported as unavailable instead of being silently mapped. + """ + text = str(value) + use_windows_rules = os.name == "nt" if windows is None else windows + if use_windows_rules: + match = re.match(r"^[\\/]([A-Za-z])[\\/](.*)$", text) + if match: + text = f"{match.group(1).upper()}:/{match.group(2)}" + return Path(text).expanduser() + + def compute_file_hash(filepath: Path) -> str: """Compute SHA256 hash of a file.""" if not filepath.exists(): diff --git a/tests/fixtures/closeout/chinese-owner-scope-long.md b/tests/fixtures/closeout/chinese-owner-scope-long.md new file mode 100644 index 0000000..68cf01c --- /dev/null +++ b/tests/fixtures/closeout/chinese-owner-scope-long.md @@ -0,0 +1 @@ +User: 我现在需要把这个收拢一些,我觉得现在的这个功能太泛了,我觉得我们现在就是把它定义为这个飞行学习包,然后每一段飞行完成一个学习主题。飞行过程中可以通过端侧 AI 互动和学习,整个流程要尽可能让用户确实学到一个主题。上飞机之前先选提前设计好的学习主题,飞机上完成学习,落地之后可以分享,这是一整个循环。之前的问题是端侧模型体验不好,没有让我觉得学到一个什么东西。学习互动到底用打字还是选项,还需要继续验证。 diff --git a/tests/test_capture.py b/tests/test_capture.py index 534802b..8c8c837 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -66,7 +66,43 @@ def test_auto_confirm_processes_explicitly_confirmed_capture(tmp_path): assert result.exit_code == 0 assert "decision(s) written" in result.output - assert "Use the smaller experiment" in (tmp_path / "DECISIONS.md").read_text() + ledger = (tmp_path / "DECISIONS.md").read_text() + assert "Use the smaller experiment" in ledger + assert "### 决策状态\nconfirmed" in ledger + assert "capture_review: .flg/captures/" in ledger + assert "未记录备选方案" not in ledger + assert "选择了当前方案,放弃其他备选方案" not in ledger + assert "待结合项目上下文补充" not in ledger + assert "通过后续执行结果和项目反馈验证" not in ledger + finally: + os.chdir(old_cwd) + + +def test_short_attributed_owner_scope_uses_capture_draft_path(tmp_path): + """A short owner contraction stays a source-attributed capture candidate.""" + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + assert runner.invoke(app, ["init", "Chinese Scope Capture Test"]).exit_code == 0 + quote = "User: 我们主要做 Canvas Prompt 和 FlowGrid,其他我觉得意义不大。" + result = runner.invoke( + app, + [ + "capture", "add", + "--claim", "FlowGrid 是两条主要海外推进线之一", + "--rationale", "Owner 明确收拢资源范围", + "--type", "decision", + "--confidence", "confirmed", + "--evidence", quote, + "--confirmation-event-id", "owner-scope-20260816-turn-1", + ], + ) + assert result.exit_code == 0 + capture = next((tmp_path / ".flg" / "captures").glob("cap-*.md")) + raw = capture.read_text(encoding="utf-8") + assert "source_actor: user" in raw + assert "status: pending_review" in raw + assert quote in raw finally: os.chdir(old_cwd) diff --git a/tests/test_closeout.py b/tests/test_closeout.py index bcc1e31..c9f9c34 100644 --- a/tests/test_closeout.py +++ b/tests/test_closeout.py @@ -1137,6 +1137,51 @@ def test_closeout_strips_code_backticks_before_matching(tmp_path): os.chdir(old_cwd) +def test_closeout_extracts_long_attributed_chinese_owner_scope(tmp_path): + """The real long-form Chinese contraction reaches a reviewable closeout candidate.""" + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + runner.invoke(app, ["init", "Long Chinese Owner Scope Test"]) + fixture = Path(__file__).parent / "fixtures" / "closeout" / "chinese-owner-scope-long.md" + result = runner.invoke(app, ["closeout", "--transcript", str(fixture), "--no-llm"]) + assert result.exit_code == 0 + patch = next((tmp_path / ".flg" / "patches").glob("closeout-*.patch.md")) + decision_section = patch.read_text(encoding="utf-8").split( + "## 2. Candidate Decisions" + )[1].split("## 3.")[0] + assert "decision_type: owner_scope_narrowing" in decision_section + assert "source_actor: user" in decision_section + assert "定义为这个飞行学习包" in decision_section + assert "端侧模型体验不好" in decision_section + assert "low_confidence_shell" not in decision_section + finally: + os.chdir(old_cwd) + + +def test_closeout_abstains_on_unattributed_long_chinese_entity_list(tmp_path): + """Scope-like words without a human actor must not become project truth.""" + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + runner.invoke(app, ["init", "Chinese Scope Abstain Test"]) + transcript = tmp_path / "entities.md" + transcript.write_text( + "产品列表包括 Canvas Prompt、FlowGrid、AML Retriever 和 Memory Runtime。" + "这份材料介绍整个流程、循环和产品范围,后面可以再收拢。\n", + encoding="utf-8", + ) + result = runner.invoke(app, ["closeout", "--transcript", str(transcript), "--no-llm"]) + assert result.exit_code == 0 + patch = next((tmp_path / ".flg" / "patches").glob("closeout-*.patch.md")) + decision_section = patch.read_text(encoding="utf-8").split( + "## 2. Candidate Decisions" + )[1].split("## 3.")[0] + assert "(no candidate decisions extracted)" in decision_section + finally: + os.chdir(old_cwd) + + def test_closeout_removed_weak_keywords_dont_fire(tmp_path): """Removed weak keywords ('优先' '边界' '选择') must NOT trigger decisions. diff --git a/tests/test_cold_start_ab.py b/tests/test_cold_start_ab.py new file mode 100644 index 0000000..4ad8a1a --- /dev/null +++ b/tests/test_cold_start_ab.py @@ -0,0 +1,30 @@ +import json +from pathlib import Path + +from evals.cold_start_ab import DEFAULT_CASE, evaluate, render_markdown + + +def test_external_case_cold_start_fixture_is_reproducible(): + report = evaluate(DEFAULT_CASE) + assert report["results"]["no_flg"]["passed"] == 1 + assert report["results"]["flg"]["passed"] == 6 + assert "Does not measure agent output quality" in report["boundary"] + rendered = render_markdown(report) + assert "Ordinary project files: 1/6" in rendered + assert "FlowGrid Context Pack: 6/6" in rendered + assert "fresh-agent output comparison is still required" in rendered + + +def test_external_case_fixture_uses_relative_local_sources(): + for path in DEFAULT_CASE.iterdir(): + assert path.is_file() + assert not Path(path.name).is_absolute() + + +def test_recorded_external_case_result_matches_the_runner(): + recorded = json.loads( + (DEFAULT_CASE.parents[1] / "results" / "external-windows-workbuddy-cold-start.json").read_text( + encoding="utf-8" + ) + ) + assert recorded == evaluate(DEFAULT_CASE) diff --git a/tests/test_decision_relations.py b/tests/test_decision_relations.py index 063eaaa..62bb7d6 100644 --- a/tests/test_decision_relations.py +++ b/tests/test_decision_relations.py @@ -125,8 +125,14 @@ def test_decision_add_without_source_does_not_invent_high_authority_evidence(tmp assert result.exit_code == 0 ledger = (tmp_path / "DECISIONS.md").read_text(encoding="utf-8") + recorded = ledger.split("## D-001 | Keep the project state local", 1)[1] assert "直接写入命令;未提供来源摘录" in ledger - assert ledger.count("未提供") >= 4 + assert recorded.count("未提供") == 1 + assert "### 备选方案" not in recorded + assert "### 放弃理由" not in recorded + assert "### 风险判断" not in recorded + assert "### 后续验证" not in recorded + assert "### 复盘入口" not in recorded assert "用户明确指令" not in ledger assert "通过后续执行结果和项目反馈验证" not in ledger diff --git a/tests/test_integrity.py b/tests/test_integrity.py index 504cee3..c180922 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -3,10 +3,13 @@ import json import os import subprocess +from pathlib import Path from typer.testing import CliRunner from flg.cli import app +from flg.core.evidence import validate_project +from flg.core.files import normalize_user_path runner = CliRunner() @@ -84,6 +87,50 @@ def test_reindex_discards_provenance_when_a_decision_id_is_reused(tmp_path): os.chdir(old_cwd) +def test_reindex_recovers_capture_provenance_declared_in_minimal_ledger(tmp_path): + old_cwd = _project(tmp_path) + try: + capture = tmp_path / ".flg" / "captures" / "cap-owner.md" + capture.parent.mkdir(parents=True, exist_ok=True) + capture.write_text("User: 只保留 FlowGrid 的本地影响。\n", encoding="utf-8") + (tmp_path / "DECISIONS.md").write_text( + """# Decision Log + +## D-001 | FlowGrid 只保存本地影响 + +### 决策状态 +confirmed + +### 最终决策 +FlowGrid 是两条主要推进线之一,AML 只作为验证证据。 + +--- + +*Decision | Source: capture_review: .flg/captures/cap-owner.md; reviewed_at: 2026-08-16T23:03:07; user_text: User: 只保留 FlowGrid 的本地影响。* +""", + encoding="utf-8", + ) + assert runner.invoke(app, ["reindex"]).exit_code == 0 + item = json.loads( + (tmp_path / ".flg" / "context" / "evidence_index.json").read_text(encoding="utf-8") + )["items"]["D-001"] + assert item["source_type"] == "capture_review" + assert item["source_capture"] == ".flg/captures/cap-owner.md" + assert item["source_excerpt"] == "User: 只保留 FlowGrid 的本地影响。" + assert item["reviewed_at"] == "2026-08-16T23:03:07" + assert runner.invoke(app, ["reindex"]).exit_code == 0 + rebuilt_twice = json.loads( + (tmp_path / ".flg" / "context" / "evidence_index.json").read_text(encoding="utf-8") + )["items"]["D-001"] + assert rebuilt_twice["reviewed_at"] == "2026-08-16T23:03:07" + assert any( + episode["source_type"] == "review_action" + for episode in rebuilt_twice["source_episodes"] + ) + finally: + os.chdir(old_cwd) + + def test_doctor_reports_index_drift_without_writing(tmp_path): old_cwd = _project(tmp_path) try: @@ -400,3 +447,32 @@ def test_doctor_strict_ignores_runtime_identity_when_repo_map_is_absent(tmp_path assert "not configured (no repo-map)" in result.output finally: os.chdir(old_cwd) + + +def test_windows_path_spellings_normalize_for_native_windows_input(): + assert str(normalize_user_path(r"C:\Users\owner\project\session.md", windows=True)) == r"C:\Users\owner\project\session.md" + assert str(normalize_user_path("C:/Users/owner/project/session.md", windows=True)) == "C:/Users/owner/project/session.md" + assert str(normalize_user_path("/c/Users/owner/project/session.md", windows=True)) == "C:/Users/owner/project/session.md" + + +def test_integrity_recognizes_all_supported_windows_path_spellings(tmp_path): + old_cwd = _project(tmp_path) + try: + assert runner.invoke(app, ["reindex"]).exit_code == 0 + state_path = tmp_path / ".flg" / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["windows_path_examples"] = [ + r"C:\Users\owner\project", + "D:/Work/project", + "/c/Users/owner/project", + ] + state["ordinary_url"] = "https://example.test/a/project" + state_path.write_text(json.dumps(state), encoding="utf-8") + + report = validate_project(Path(tmp_path)) + assert len(report["legacy_paths"]) == 3 + assert any(item.startswith("C:\\") for item in report["legacy_paths"]) + assert any(item.startswith("D:/") for item in report["legacy_paths"]) + assert any(item.startswith("/c/") for item in report["legacy_paths"]) + finally: + os.chdir(old_cwd) diff --git a/tests/test_smoke_command_resolution.py b/tests/test_smoke_command_resolution.py index e26659f..4c32100 100644 --- a/tests/test_smoke_command_resolution.py +++ b/tests/test_smoke_command_resolution.py @@ -18,3 +18,18 @@ def test_source_tree_fallback_preserves_dependency_pythonpath(monkeypatch, tmp_p "PYTHONPATH": f"{tmp_path / 'src'}:/tmp/dependency-site" } assert mode == "repository source tree" + + +def test_forced_source_tree_ignores_sibling_console_script(monkeypatch, tmp_path): + monkeypatch.setattr(smoke_test, "REPO_ROOT", tmp_path) + monkeypatch.setattr(smoke_test.sys, "executable", "/venv/bin/python") + monkeypatch.setenv("FLOWGRID_SMOKE_SOURCE", "1") + monkeypatch.setenv("PYTHONPATH", "/tmp/dependency-site") + + command, environment, mode = smoke_test.resolve_flg_command() + + assert command == ["/venv/bin/python", "-m", "flg.cli"] + assert environment == { + "PYTHONPATH": f"{tmp_path / 'src'}:/tmp/dependency-site" + } + assert mode == "forced repository source tree"