pi-anchorloop 0.0.0-stage → 2.0.1
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/LICENSE +21 -0
- package/README.md +63 -2
- package/README.zh-TW.md +64 -0
- package/hooks/validate_tool_use.py +88 -0
- package/hooks/validators/__init__.py +1 -0
- package/hooks/validators/constants.py +47 -0
- package/hooks/validators/durable_validator.py +89 -0
- package/hooks/validators/rule_validator.py +42 -0
- package/hooks/validators/skill_validator.py +144 -0
- package/hooks/validators/utils.py +71 -0
- package/package.json +44 -4
- package/pi/anchor-gate.ts +84 -0
- package/pi/decision-tool.ts +88 -0
- package/pi/second-thought.ts +242 -0
- package/pi/support/validators.ts +403 -0
- package/pi/ttsr.ts +516 -0
- package/pi/validate-tool-use.ts +27 -0
- package/skills/agent-terse/SKILL.md +46 -0
- package/skills/anchorloop/PRINCIPLES.md +34 -0
- package/skills/anchorloop/SKILL.md +76 -0
- package/skills/anchorloop/playbooks/autonomous-run.md +12 -0
- package/skills/anchorloop/playbooks/pause.md +10 -0
- package/skills/anchorloop/playbooks/pickup.md +11 -0
- package/skills/answering/SKILL.md +19 -0
- package/skills/answering/playbooks/compare.md +9 -0
- package/skills/answering/playbooks/explain.md +9 -0
- package/skills/coding/MINI-SDD.md +61 -0
- package/skills/coding/SKILL.md +27 -0
- package/skills/coding/playbooks/bug-fix.md +11 -0
- package/skills/coding/playbooks/feature.md +15 -0
- package/skills/coding/playbooks/perf.md +10 -0
- package/skills/coding/playbooks/prototype.md +14 -0
- package/skills/coding/playbooks/refactoring.md +12 -0
- package/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +48 -0
- package/skills/grilling/SKILL.md +30 -0
- package/skills/human-feedback/SKILL.md +83 -0
- package/skills/inspecting/SKILL.md +49 -0
- package/skills/inspecting/playbooks/diff-review.md +57 -0
- package/skills/inspecting/playbooks/qa-release.md +9 -0
- package/skills/investigating/SKILL.md +22 -0
- package/skills/investigating/playbooks/diagnose.md +9 -0
- package/skills/investigating/playbooks/research.md +9 -0
- package/skills/solid-loop/SKILL.md +50 -0
- package/skills/solid-loop/playbooks/apply-friction.md +15 -0
- package/skills/solid-loop/playbooks/author-skill.md +11 -0
- package/skills/solid-loop/playbooks/bootstrap.md +10 -0
- package/skills/solid-loop/references/template-skill.md +26 -0
- package/skills/writing/SKILL.md +16 -0
- package/skills/writing/playbooks/comments.md +10 -0
- package/skills/writing/playbooks/draft.md +10 -0
- package/skills/writing/playbooks/tighten.md +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wei Hung
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-anchorloop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[繁體中文](README.zh-TW.md)
|
|
4
|
+
|
|
5
|
+
Agent workflow skills and loop extensions for [pi](https://github.com/earendil-works/pi). Every main task type enters through its own skill, picks a playbook, and runs one shared loop: **Align → Anchor → Act → Verify**.
|
|
6
|
+
|
|
7
|
+
## Skills
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
anchorloop core loop · principles · playbooks: autonomous-run, pause, pickup
|
|
11
|
+
├─ answering explain · compare
|
|
12
|
+
├─ investigating research · diagnose
|
|
13
|
+
├─ inspecting diff-review · qa-release
|
|
14
|
+
├─ coding bug-fix · feature · refactoring · perf · prototype (+ Mini SDD)
|
|
15
|
+
├─ writing draft · tighten · comments
|
|
16
|
+
└─ solid-loop apply-friction · author-skill · bootstrap
|
|
17
|
+
shared grilling · domain-modeling · human-feedback · agent-terse
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **`anchorloop`** holds what is true for every task type: restate the task, pick the entry's playbook and copy its steps into `todo`, state a `Reality anchor:` before the first change, work in small increments deleting before adding, verify against the anchor, and record friction notes. The Reflexive pass hands friction notes to `solid-loop`. `PRINCIPLES.md` names the principles playbooks cite and the user can steer with. The lifecycle playbooks cover unattended runs, clean pauses, and picking up a prior run.
|
|
21
|
+
- **Entry skills** each name the anchor for their task type and index their playbooks. A playbook has an ownership line, numbered steps, and a `Reply:` line that sets what the final report contains.
|
|
22
|
+
- **Shared skills** are called by entries: `grilling` for user-owned decisions, `domain-modeling` for terms and ADRs, `human-feedback` for reactions to working output, `agent-terse` for text only agents read: briefs, reports, and agent messages.
|
|
23
|
+
|
|
24
|
+
## Extensions
|
|
25
|
+
|
|
26
|
+
| File | What it does |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `pi/anchor-gate.ts` | Shows one warning per user message when `write` or `edit` runs before the assistant states `Reality anchor:`; the call still goes through. `.md`, `.txt`, and `.html` files, `scratch/`, `docs/specs/`, and temp files outside the project pass silently. Edits made through `bash` are not checked. |
|
|
29
|
+
| `pi/decision-tool.ts` | The `anchorloop_decision` tool appends single-line, spreadsheet-safe rows to a decision trail TSV, or a `start` row when a run picks up another run's trail. |
|
|
30
|
+
| `pi/second-thought.ts` | Before a run that called `write`, `edit`, or `bash` settles, asks Jev (TypeSafe's typed-decision model, through pi's classifier registry) whether the final reply shows the request met, which review tier it needs, and its quality. Jev also says whether the reply is a checkpoint that hands work back to the user; a reply that only acknowledges, reports progress, or waits gets no reminder. At a checkpoint, when quality < 1.5 or the tier is `full`, it adds one visible advisory message and one continuation per run; the agent may disagree and finish. When only done < 0.25, the message is shown without a continuation. The request is the latest message the user typed; messages extensions send (intercom, monitor wakes) do not replace it. A run such a message starts is sent with `trigger: wake` and that message, and is judged on what it asks; its checkpoint question adds that a reply to a routine check the wake asked for (status, health, load, progress of running work) is not a checkpoint. A run that called `MonitorCreate`, `LoopCreate`, `goal_wait`, or a `subagent` tool is waiting on a wake and is not judged. Only the request, the trigger, the wake message, and the final reply are sent. Without a TypeSafe key it records `unknown` and does nothing. In a subagent session (`PI_SUBAGENT_ID` from pi-herdr-agents, or `PI_SUBAGENT_CHILD` from pi-subagents) it registers nothing, since the agent that spawned it judges the result. |
|
|
31
|
+
| `pi/validate-tool-use.ts` | After each `write` or `edit`, validates the file in-process (`pi/support/validators.ts`, a port of `hooks/validators/`) and appends suggestions for `SKILL.md`, instruction files (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `rules/`), and `docs/specs/` artifacts. Needs no Python; the Python validators in `hooks/` remain for `validate_all.py` and the parity test. |
|
|
32
|
+
| `pi/ttsr.ts` | A port of oh-my-pi's Time Traveling Stream Rules. A Markdown rule in `~/.pi/agent/rules/` or `<project>/.pi/rules/` whose frontmatter has a `condition` regex is watched during the run. A match in streamed assistant text aborts the response, drops it, and retries with the rule injected; a match in tool arguments (the command for `bash`, the new text for `write` and `edit`) blocks the call with the rule as its reason. Frontmatter fields follow oh-my-pi: `condition`, `scope` (`text`, `thinking`, `tool`, `tool:bash`, `tool:edit(*.ts)`), `globs`, and `interruptMode` (`always`, `prose-only`, `tool-only`, `never`), plus `repeatMode` (`once` or `after-gap`) and `repeatGap`. A rule fires once per session by default. `astCondition`, `question`, and `agents` are not supported; a rule that uses them is skipped and reported. `/ttsr` lists the loaded rules. |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Install the npm package `pi-anchorloop`; it is the recommended way to use anchorloop:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install npm:pi-anchorloop
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A local checkout loads in place, so edits take effect on `/reload`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi install /path/to/anchorloop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Develop
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv run python hooks/validate_all.py . # skills, rules, and durable specs
|
|
52
|
+
uv run --with pytest pytest -q # validators and the pi extensions (needs node)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Acknowledgments
|
|
56
|
+
|
|
57
|
+
- [pstack](https://github.com/backnotprop/pstack) by Lauren Tan (MIT), mirror revision `e44668c` of [cursor/plugins](https://github.com/cursor/plugins/tree/main/pstack), and its port [pstack-claude](https://github.com/michael-denyer/pstack-claude) by Michael Denyer (MIT): task-type playbooks, named principles, the lifecycle playbooks, the decision trail, the closed review gate, fresh-subagent hand-off, and evidence grades.
|
|
58
|
+
- [google-research/harness2](https://github.com/google-research/harness2) (Apache-2.0), revision `1cfe254`: solid-loop's placement test, cheapest-mechanism order, and declining a lesson without a before/after contrast.
|
|
59
|
+
- [mattpocock/skills](https://github.com/mattpocock/skills) (MIT): grilling, domain-modeling, the diff review, the prototype playbook, and the feature playbook's interface checks.
|
|
60
|
+
- [JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman) (Apache-2.0), revision `bad48a0`: the terse voice in `agent-terse`.
|
|
61
|
+
- HumanLayer's [show-me](https://github.com/humanlayer/skills/tree/main/plugins/show-me) (MIT): the views in `answering`.
|
|
62
|
+
- [oh-my-pi](https://github.com/can1357/oh-my-pi): Time Traveling Stream Rules.
|
|
63
|
+
|
|
64
|
+
Ported files credit their sources in their own footers.
|
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pi-anchorloop
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
給 [pi](https://github.com/earendil-works/pi) 用的 agent 工作流程 skill 和循環 extension。每一種主要任務都從自己的 skill 進來,選一本 playbook,然後跑同一個循環:**Align → Anchor → Act → Verify**。
|
|
6
|
+
|
|
7
|
+
## Skill
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
anchorloop 核心循環 · 原則 · playbook:autonomous-run、pause、pickup
|
|
11
|
+
├─ answering explain · compare
|
|
12
|
+
├─ investigating research · diagnose
|
|
13
|
+
├─ inspecting diff-review · qa-release
|
|
14
|
+
├─ coding bug-fix · feature · refactoring · perf · prototype(+ Mini SDD)
|
|
15
|
+
├─ writing draft · tighten · comments
|
|
16
|
+
└─ solid-loop apply-friction · author-skill · bootstrap
|
|
17
|
+
共用 grilling · domain-modeling · human-feedback · agent-terse
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **`anchorloop`** 放每一種任務都成立的做法:重述任務、選入口的 playbook 並把步驟抄進 `todo`、第一筆修改前寫出 `Reality anchor:`、以小步前進且先刪後加、依 anchor 驗證,並記下 friction note。Reflexive pass 會把 friction note 交給 `solid-loop`。`PRINCIPLES.md` 列出 playbook 引用的原則,使用者也可以用這些名稱中途修正方向。三本生命週期 playbook 處理無人看管的執行、乾淨的暫停,以及接手前一輪的工作。
|
|
21
|
+
- **入口 skill** 各自寫出這類任務的 anchor,並列出自己的 playbook。每本 playbook 先用一句話說明它負責什麼,接著是編號步驟,最後一行 `Reply:` 規定回報要包含什麼。
|
|
22
|
+
- **共用 skill** 由入口呼叫:`grilling` 處理使用者要做的決定,`domain-modeling` 處理術語和 ADR,`human-feedback` 處理對成品的反應,`agent-terse` 處理只給 agent 讀的文字:任務 brief、回報、agent 之間的訊息。
|
|
23
|
+
|
|
24
|
+
## Extension
|
|
25
|
+
|
|
26
|
+
| 檔案 | 作用 |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `pi/anchor-gate.ts` | 使用者最新一則訊息之後,assistant 還沒寫出 `Reality anchor:` 就執行 `write` 或 `edit` 時,每則訊息顯示一次警告,但不擋下呼叫。`.md`、`.txt`、`.html` 檔、`scratch/`、`docs/specs/` 和專案外的暫存檔不警告。透過 `bash` 改檔不在檢查範圍內。 |
|
|
29
|
+
| `pi/decision-tool.ts` | `anchorloop_decision` 工具把一筆決策寫進 TSV 決策軌跡,每筆只佔一行,用試算表開啟也安全;接手別人的軌跡時寫一筆 `start`。 |
|
|
30
|
+
| `pi/second-thought.ts` | 呼叫過 `write`、`edit` 或 `bash` 的執行結束前,透過 pi 的 classifier registry 問 Jev(TypeSafe 的型別化決策模型):最後的回覆有沒有證明需求已達成、需要哪一級 review、品質如何。Jev 也會判斷這則回覆是不是提醒點,也就是把工作交回給使用者的地方;只是確認收到、回報進度或等待中的回覆不提醒。在提醒點上,quality < 1.5 或 review 是 `full` 時,加一則看得到的提醒並讓 agent 多跑一輪,每輪最多一次;agent 可以不同意,直接結束。只有 done < 0.25 時,只顯示提醒,不多跑一輪。需求是使用者最新打的訊息;extension 送進來的訊息(intercom、monitor 喚醒)不會取代它。由這類訊息開始的那一輪,會連同 `trigger: wake` 和那則訊息一起送出,依那則訊息要求的事評估;這時的提醒點問題會多說明:回覆喚醒訊息要求的例行檢查(狀態、健康、負載、還在跑的工作進度)不算提醒點。呼叫過 `MonitorCreate`、`LoopCreate`、`goal_wait` 或 `subagent` 類工具的執行是在等喚醒,不評估。只送出需求、trigger、喚醒訊息和最後的回覆。沒有 TypeSafe key 時記為 `unknown`,不做任何事。在 subagent session 裡(pi-herdr-agents 的 `PI_SUBAGENT_ID`,或 pi-subagents 的 `PI_SUBAGENT_CHILD`)完全不啟用,結果交給派出它的 agent 判斷。 |
|
|
31
|
+
| `pi/validate-tool-use.ts` | 每次 `write` 或 `edit` 後在行程內驗證該檔(`pi/support/validators.ts`,移植自 `hooks/validators/`),替 `SKILL.md`、指令檔(`AGENTS.md`、`CLAUDE.md`、`GEMINI.md`、`rules/`)和 `docs/specs/` 產出物附上建議。不需要 Python;`hooks/` 的 Python 驗證器仍供 `validate_all.py` 和對照測試使用。 |
|
|
32
|
+
| `pi/ttsr.ts` | 移植自 oh-my-pi 的 Time Traveling Stream Rules。`~/.pi/agent/rules/` 或 `<專案>/.pi/rules/` 裡 frontmatter 帶有 `condition` regex 的 Markdown 規則,會在執行過程中被監看。assistant 串流文字命中時,中斷回應、丟棄那段輸出,並注入規則後重試;工具參數命中時(`bash` 看指令,`write`/`edit` 看新寫入的文字),直接擋下呼叫,以規則內容作為理由。frontmatter 欄位沿用 oh-my-pi:`condition`、`scope`(`text`、`thinking`、`tool`、`tool:bash`、`tool:edit(*.ts)`)、`globs`、`interruptMode`(`always`、`prose-only`、`tool-only`、`never`),另加 `repeatMode`(`once` 或 `after-gap`)與 `repeatGap`。預設每條規則每個 session 只觸發一次。不支援 `astCondition`、`question`、`agents`,用到的規則會被跳過並回報。`/ttsr` 列出已載入的規則。 |
|
|
33
|
+
|
|
34
|
+
## 安裝
|
|
35
|
+
|
|
36
|
+
建議安裝 npm 套件 `pi-anchorloop` 來使用 anchorloop:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install npm:pi-anchorloop
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
本機 checkout 會直接載入,改完 `/reload` 就生效:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi install /path/to/anchorloop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 開發
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv run python hooks/validate_all.py . # 檢查 skill、規則與 durable spec
|
|
52
|
+
uv run --with pytest pytest -q # 驗證器與 pi extension 的測試(需要 node)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 致謝
|
|
56
|
+
|
|
57
|
+
- Lauren Tan 的 [pstack](https://github.com/backnotprop/pstack)(MIT),鏡像 repo 的版本 `e44668c`,上游是 [cursor/plugins](https://github.com/cursor/plugins/tree/main/pstack);以及 Michael Denyer 的移植版 [pstack-claude](https://github.com/michael-denyer/pstack-claude)(MIT):依任務類型分的 playbook、具名原則、生命週期 playbook、決策軌跡、獨立審查前不放行的關卡、交接給新 subagent,以及證據分級。
|
|
58
|
+
- [google-research/harness2](https://github.com/google-research/harness2)(Apache-2.0),版本 `1cfe254`:solid-loop 的放置判斷、最便宜機制的順序,以及沒有前後對照就不寫成教訓。
|
|
59
|
+
- [mattpocock/skills](https://github.com/mattpocock/skills)(MIT):grilling、domain-modeling、diff review、prototype playbook,以及 feature playbook 的介面檢查。
|
|
60
|
+
- [JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman)(Apache-2.0),revision `bad48a0`:`agent-terse` 的精簡寫法。
|
|
61
|
+
- HumanLayer 的 [show-me](https://github.com/humanlayer/skills/tree/main/plugins/show-me)(MIT):`answering` 的呈現方式。
|
|
62
|
+
- [oh-my-pi](https://github.com/can1357/oh-my-pi):Time Traveling Stream Rules。
|
|
63
|
+
|
|
64
|
+
移植來的檔案在各自的頁尾註明出處。
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validator entrypoint called by the pi extension in pi/validate-tool-use.ts.
|
|
3
|
+
|
|
4
|
+
Reads {"tool_input": {"file_path": ...}, "cwd": ...} on stdin, validates the
|
|
5
|
+
edited file against skill compactness, direct expected behavior, and durable
|
|
6
|
+
review standards, and prints {"additionalContext": ...} when it has advice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from validators.durable_validator import check_durable_spec
|
|
14
|
+
from validators.rule_validator import check_rules_file
|
|
15
|
+
from validators.skill_validator import check_skill_advisories, check_skill_md
|
|
16
|
+
from validators.utils import extract_file_path_from_payload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_validation(path: Path) -> list[str]:
|
|
20
|
+
"""Route file to appropriate validator based on path and filename."""
|
|
21
|
+
warnings: list[str] = []
|
|
22
|
+
name = path.name.lower()
|
|
23
|
+
path_str = str(path).replace("\\", "/")
|
|
24
|
+
|
|
25
|
+
# 1. SKILL.md
|
|
26
|
+
if name == "skill.md":
|
|
27
|
+
warnings.extend(check_skill_md(path))
|
|
28
|
+
warnings.extend(check_skill_advisories(path))
|
|
29
|
+
|
|
30
|
+
# 2. Rule and instruction files
|
|
31
|
+
elif name in ("agents.md", "claude.md", "gemini.md") or "/rules/" in path_str:
|
|
32
|
+
warnings.extend(check_rules_file(path))
|
|
33
|
+
|
|
34
|
+
# 3. Durable spec artifacts
|
|
35
|
+
elif "/docs/specs/" in path_str and name.endswith(".md"):
|
|
36
|
+
warnings.extend(check_durable_spec(path))
|
|
37
|
+
|
|
38
|
+
return warnings
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
try:
|
|
43
|
+
raw_input = sys.stdin.read()
|
|
44
|
+
if not raw_input.strip():
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
data = json.loads(raw_input)
|
|
47
|
+
except Exception:
|
|
48
|
+
# Never block execution if input parsing fails
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
file_path_str = extract_file_path_from_payload(data)
|
|
53
|
+
if not file_path_str:
|
|
54
|
+
print(json.dumps({}))
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
cwd_str = data.get("cwd", "")
|
|
58
|
+
cwd = Path(cwd_str) if cwd_str else Path.cwd()
|
|
59
|
+
target_path = Path(file_path_str)
|
|
60
|
+
if not target_path.is_absolute():
|
|
61
|
+
target_path = cwd / target_path
|
|
62
|
+
|
|
63
|
+
if not target_path.exists():
|
|
64
|
+
print(json.dumps({}))
|
|
65
|
+
sys.exit(0)
|
|
66
|
+
|
|
67
|
+
warnings = run_validation(target_path)
|
|
68
|
+
if warnings:
|
|
69
|
+
try:
|
|
70
|
+
rel = target_path.relative_to(cwd)
|
|
71
|
+
except ValueError:
|
|
72
|
+
rel = target_path
|
|
73
|
+
|
|
74
|
+
lines = "\n".join(f" - {w}" for w in warnings)
|
|
75
|
+
msg = f"⚠ [anchorloop] Validation suggestions for {rel}:\n{lines}"
|
|
76
|
+
print(json.dumps({"additionalContext": msg}))
|
|
77
|
+
else:
|
|
78
|
+
print(json.dumps({}))
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
sys.stderr.write(f"anchorloop hook error: {e}\n")
|
|
82
|
+
print(json.dumps({}))
|
|
83
|
+
|
|
84
|
+
sys.exit(0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Anchorloop validators package."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Constants for skill, rule, and durable spec validation."""
|
|
2
|
+
|
|
3
|
+
# Allowed fields in SKILL.md YAML frontmatter
|
|
4
|
+
SKILL_ALLOWED_FIELDS = frozenset({"name", "description"})
|
|
5
|
+
|
|
6
|
+
# Maximum recommended lines for a self-contained SKILL.md before progressive disclosure (official standard: 300 lines)
|
|
7
|
+
SKILL_MAX_RECOMMENDED_LINES = 300
|
|
8
|
+
|
|
9
|
+
# Defensive phrasing patterns to discourage (encourage stating expected behavior directly)
|
|
10
|
+
DEFENSIVE_PATTERNS = [
|
|
11
|
+
r"never under any circumstances",
|
|
12
|
+
r"under no circumstances",
|
|
13
|
+
r"you must never ever",
|
|
14
|
+
r"do not under any condition",
|
|
15
|
+
r"strict red line",
|
|
16
|
+
r"absolute red line",
|
|
17
|
+
r"prohibited at all costs",
|
|
18
|
+
r"zero tolerance for any",
|
|
19
|
+
r"absolutely forbidden to ever",
|
|
20
|
+
r"無限多的紅線",
|
|
21
|
+
r"千萬不要",
|
|
22
|
+
r"嚴禁在任何情況下",
|
|
23
|
+
r"絕對不可",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# Red-line advisories: markers are counted against a budget of max(MIN_BUDGET, lines * DENSITY).
|
|
27
|
+
# Existing skills peak at 0.067 markers per line, so the budget fires only on clear inflation.
|
|
28
|
+
RED_LINE_MARKERS = (
|
|
29
|
+
r"\b(never|must not|do not|don't|forbidden|barred|prohibited|avoid)\b"
|
|
30
|
+
r"|不要|禁止|不得|不准|嚴禁"
|
|
31
|
+
)
|
|
32
|
+
RED_LINE_MIN_BUDGET = 3
|
|
33
|
+
RED_LINE_DENSITY = 0.10
|
|
34
|
+
# Emphatic capitals are matched case-sensitively and need a reason on the same or next line.
|
|
35
|
+
EMPHATIC_WORDS = r"\b(NEVER|ALWAYS|MUST NOT|MUST|FORBIDDEN|DO NOT)\b"
|
|
36
|
+
REASON_WORDS = r"\b(because|otherwise|so that|since)\b|否則|因為|以免"
|
|
37
|
+
|
|
38
|
+
# Essential core rules to check during durable pre-flight
|
|
39
|
+
CORE_RULES = [
|
|
40
|
+
"Traditional Chinese communication",
|
|
41
|
+
"English instructions and prompts",
|
|
42
|
+
"Direct statement of expected behavior without defensive phrasing",
|
|
43
|
+
"Anchorloop reality anchor named before first production edit",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Standard verification result states
|
|
47
|
+
VALID_VERIFICATION_RESULTS = {"pass", "fail", "unknown"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Validator for Durable artifacts and reviews in docs/specs/*/."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .constants import DEFENSIVE_PATTERNS, VALID_VERIFICATION_RESULTS
|
|
7
|
+
from .utils import find_defensive_phrases
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_decision_md(path: Path) -> list[str]:
|
|
11
|
+
"""Check decision.md for required structure and pre-flight skill/rule readiness."""
|
|
12
|
+
warnings: list[str] = []
|
|
13
|
+
text = path.read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
# Check for decision table
|
|
16
|
+
if not re.search(r"\|\s*Question\s*\|\s*Answer\s*\|\s*Basis\s*\|\s*Status\s*\|", text, re.IGNORECASE):
|
|
17
|
+
warnings.append("decision.md should include a Question | Answer | Basis | Status table.")
|
|
18
|
+
|
|
19
|
+
# Check for pre-flight readiness (core rules)
|
|
20
|
+
lower = text.lower()
|
|
21
|
+
if "rule" not in lower and "readiness" not in lower and "prerequisite" not in lower and "core" not in lower:
|
|
22
|
+
warnings.append(
|
|
23
|
+
"confirm core rules readiness before implementation; note readiness in decision.md."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return warnings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_spec_md(path: Path) -> list[str]:
|
|
30
|
+
"""Check spec.md for approval status and reality anchor definition."""
|
|
31
|
+
warnings: list[str] = []
|
|
32
|
+
text = path.read_text(encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
# Status header
|
|
35
|
+
if not re.search(r"Status:\s*(proposed|approved)", text, re.IGNORECASE):
|
|
36
|
+
warnings.append("spec.md must declare 'Status: proposed' or 'Status: approved' at the top.")
|
|
37
|
+
|
|
38
|
+
# Reality anchor declaration
|
|
39
|
+
if "reality anchor" not in text.lower() and "anchor" not in text.lower():
|
|
40
|
+
warnings.append("spec.md should name the chosen reality anchor and its checkpoint.")
|
|
41
|
+
|
|
42
|
+
return warnings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_verification_md(path: Path) -> list[str]:
|
|
46
|
+
"""Check verification.md for standard evidence rows and adjustment-focused reviews."""
|
|
47
|
+
warnings: list[str] = []
|
|
48
|
+
text = path.read_text(encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
# 1. Standard Requirement | Evidence | Result table
|
|
51
|
+
table_match = re.search(r"\|\s*Requirement\s*\|\s*Evidence\s*\|\s*Result\s*\|", text, re.IGNORECASE)
|
|
52
|
+
if not table_match:
|
|
53
|
+
warnings.append(
|
|
54
|
+
"verification.md must include a 'Requirement | Evidence | Result' table."
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
# Check that results use valid states (excluding the table header)
|
|
58
|
+
result_rows = re.findall(r"\|\s*[^|]+\s*\|\s*[^|]+\s*\|\s*([a-zA-Z]+)\s*\|", text)
|
|
59
|
+
invalid_results = [
|
|
60
|
+
r.lower() for r in result_rows
|
|
61
|
+
if r.lower() not in VALID_VERIFICATION_RESULTS and r.lower() != "result"
|
|
62
|
+
]
|
|
63
|
+
if invalid_results:
|
|
64
|
+
warnings.append(
|
|
65
|
+
f"invalid verification Result values found: {', '.join(set(invalid_results))}. "
|
|
66
|
+
"Result must be one of: pass, fail, unknown."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 2. Review of newly added rules or skills (tuning vs defensive vocabulary)
|
|
70
|
+
found_defensive = find_defensive_phrases(text, DEFENSIVE_PATTERNS)
|
|
71
|
+
if found_defensive:
|
|
72
|
+
warnings.append(
|
|
73
|
+
f"detected defensive wording in verification/review: {', '.join(found_defensive)}. "
|
|
74
|
+
"Reviews of new rules and skills must focus on adjustment and tuning, not adding defensive red lines."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return warnings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_durable_spec(path: Path) -> list[str]:
|
|
81
|
+
"""Route durable spec file to specific validator."""
|
|
82
|
+
name = path.name.lower()
|
|
83
|
+
if name == "decision.md" or name == "requirements.md":
|
|
84
|
+
return check_decision_md(path)
|
|
85
|
+
elif name == "spec.md":
|
|
86
|
+
return check_spec_md(path)
|
|
87
|
+
elif name == "verification.md":
|
|
88
|
+
return check_verification_md(path)
|
|
89
|
+
return []
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Validator for rule and instruction files (AGENTS.md, CLAUDE.md, rules/*.md, rules/*.rules)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .constants import DEFENSIVE_PATTERNS
|
|
6
|
+
from .utils import find_defensive_phrases
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_rules_file(path: Path) -> list[str]:
|
|
10
|
+
"""Check rule files for direct expected behavior and anti-bloat."""
|
|
11
|
+
warnings: list[str] = []
|
|
12
|
+
try:
|
|
13
|
+
text = path.read_text(encoding="utf-8")
|
|
14
|
+
except Exception as e:
|
|
15
|
+
return [f"failed to read rule file: {e}"]
|
|
16
|
+
|
|
17
|
+
# 1. Defensive phrasing detection
|
|
18
|
+
found_patterns = find_defensive_phrases(text, DEFENSIVE_PATTERNS)
|
|
19
|
+
if found_patterns:
|
|
20
|
+
warnings.append(
|
|
21
|
+
f"detected defensive wording: {', '.join(found_patterns)}. "
|
|
22
|
+
"State desired behavior directly; modern models do not need excessive red lines."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# 2. Rule proliferation / bloat check
|
|
26
|
+
lines = [line.strip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")]
|
|
27
|
+
if len(lines) > 200:
|
|
28
|
+
warnings.append(
|
|
29
|
+
f"rule file has {len(lines)} non-empty lines. Consider consolidating into higher-level principles "
|
|
30
|
+
"or separating into specialized modules rather than accumulating micro-rules."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 3. Check for Simplified Chinese common distinctive characters
|
|
34
|
+
# (Simple heuristic check for characters exclusively used in Simplified Chinese)
|
|
35
|
+
simplified_chinese_chars = "为这就从时会过发对门书见长经说样开"
|
|
36
|
+
found_sc = [c for c in simplified_chinese_chars if c in text]
|
|
37
|
+
if found_sc:
|
|
38
|
+
warnings.append(
|
|
39
|
+
f"possible Simplified Chinese detected ({''.join(found_sc[:5])}). Always use Traditional Chinese."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return warnings
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Validator for SKILL.md files focusing on compactness and direct expected behavior."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .constants import (
|
|
8
|
+
DEFENSIVE_PATTERNS,
|
|
9
|
+
EMPHATIC_WORDS,
|
|
10
|
+
REASON_WORDS,
|
|
11
|
+
RED_LINE_DENSITY,
|
|
12
|
+
RED_LINE_MARKERS,
|
|
13
|
+
RED_LINE_MIN_BUDGET,
|
|
14
|
+
SKILL_ALLOWED_FIELDS,
|
|
15
|
+
SKILL_MAX_RECOMMENDED_LINES,
|
|
16
|
+
)
|
|
17
|
+
from .utils import (
|
|
18
|
+
extract_markdown_links,
|
|
19
|
+
find_defensive_phrases,
|
|
20
|
+
parse_frontmatter,
|
|
21
|
+
strip_code,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_skill_md(path: Path) -> list[str]:
|
|
26
|
+
"""Run streamlining and standardization checks on a SKILL.md file."""
|
|
27
|
+
warnings: list[str] = []
|
|
28
|
+
try:
|
|
29
|
+
text = path.read_text(encoding="utf-8")
|
|
30
|
+
except Exception as e:
|
|
31
|
+
return [f"failed to read SKILL.md: {e}"]
|
|
32
|
+
|
|
33
|
+
skill_dir = path.parent
|
|
34
|
+
lines = text.splitlines()
|
|
35
|
+
|
|
36
|
+
# 1. Frontmatter check
|
|
37
|
+
fields = parse_frontmatter(text)
|
|
38
|
+
if fields is None:
|
|
39
|
+
warnings.append("missing or invalid YAML frontmatter (--- ... ---)")
|
|
40
|
+
else:
|
|
41
|
+
extra_fields = sorted(set(fields.keys()) - SKILL_ALLOWED_FIELDS)
|
|
42
|
+
if extra_fields:
|
|
43
|
+
warnings.append(f"extra frontmatter fields: {', '.join(extra_fields)}")
|
|
44
|
+
|
|
45
|
+
if "name" not in fields or not fields["name"].strip():
|
|
46
|
+
warnings.append("missing required frontmatter field: 'name'")
|
|
47
|
+
|
|
48
|
+
# The regex parser accepts what pi's YAML parser rejects, and pi then skips the whole skill.
|
|
49
|
+
for key in ("name", "description"):
|
|
50
|
+
raw = next((line.split(":", 1)[1].strip() for line in lines[1:] if line.startswith(f"{key}:")), "")
|
|
51
|
+
if raw and raw[0] not in "\"'|>" and (": " in raw or " #" in raw):
|
|
52
|
+
warnings.append(f"quote the {key}: YAML reads an unquoted ': ' or ' #' as syntax, and pi skips the skill")
|
|
53
|
+
|
|
54
|
+
if "description" not in fields or not fields["description"].strip():
|
|
55
|
+
warnings.append("missing required frontmatter field: 'description'")
|
|
56
|
+
else:
|
|
57
|
+
desc = fields["description"]
|
|
58
|
+
if "use when" not in desc.lower():
|
|
59
|
+
warnings.append("description should specify trigger criteria (include 'Use when...')")
|
|
60
|
+
|
|
61
|
+
# 2. Compactness check (streamlined skills)
|
|
62
|
+
if len(lines) > SKILL_MAX_RECOMMENDED_LINES:
|
|
63
|
+
has_references = (skill_dir / "references").is_dir()
|
|
64
|
+
if not has_references:
|
|
65
|
+
warnings.append(
|
|
66
|
+
f"skill has {len(lines)} lines (exceeds recommended {SKILL_MAX_RECOMMENDED_LINES}). "
|
|
67
|
+
"Move detailed guidelines or templates to references/ to keep SKILL.md streamlined."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# 3. Broken markdown links, followed through every Markdown file SKILL.md reaches
|
|
71
|
+
root = skill_dir.resolve()
|
|
72
|
+
reached: dict[Path, str] = {path.resolve(): text}
|
|
73
|
+
linked: set[Path] = set()
|
|
74
|
+
pending = [path.resolve()]
|
|
75
|
+
while pending:
|
|
76
|
+
source = pending.pop()
|
|
77
|
+
for link in extract_markdown_links(reached[source]):
|
|
78
|
+
target = (source.parent / link).resolve()
|
|
79
|
+
if not target.exists():
|
|
80
|
+
where = "" if source == path.resolve() else f" (in {source.relative_to(root).as_posix()})"
|
|
81
|
+
warnings.append(f"broken local link: {link}{where}")
|
|
82
|
+
continue
|
|
83
|
+
linked.add(target)
|
|
84
|
+
if target.suffix == ".md" and target.is_relative_to(root) and target not in reached:
|
|
85
|
+
reached[target] = target.read_text(encoding="utf-8")
|
|
86
|
+
pending.append(target)
|
|
87
|
+
|
|
88
|
+
# 4. Orphaned files check
|
|
89
|
+
corpus = "\n".join(reached.values())
|
|
90
|
+
for f in skill_dir.rglob("*"):
|
|
91
|
+
if f == path or f.is_dir():
|
|
92
|
+
continue
|
|
93
|
+
rel = f.relative_to(skill_dir).as_posix()
|
|
94
|
+
if not (f.resolve() in linked or rel in corpus or f.name in corpus):
|
|
95
|
+
warnings.append(f"orphaned file not referenced in SKILL.md: {rel}")
|
|
96
|
+
|
|
97
|
+
# 5. Defensive phrasing check
|
|
98
|
+
found_patterns = find_defensive_phrases(text, DEFENSIVE_PATTERNS)
|
|
99
|
+
if found_patterns:
|
|
100
|
+
warnings.append(
|
|
101
|
+
f"detected defensive anti-pattern(s): {', '.join(found_patterns)}. "
|
|
102
|
+
"State expected behavior directly rather than constructing defensive red lines."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return warnings
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_skill_advisories(path: Path) -> list[str]:
|
|
109
|
+
"""Red-line advisories for the author; they never count as validation failures."""
|
|
110
|
+
try:
|
|
111
|
+
text = path.read_text(encoding="utf-8")
|
|
112
|
+
except Exception:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
body = strip_code(text)
|
|
116
|
+
lines = body.splitlines()
|
|
117
|
+
advisories: list[str] = []
|
|
118
|
+
|
|
119
|
+
# 1. Budget: red lines are earned by an observed failure, so their count stays small.
|
|
120
|
+
markers = len(re.findall(RED_LINE_MARKERS, body, re.IGNORECASE))
|
|
121
|
+
budget = max(RED_LINE_MIN_BUDGET, int(sum(1 for line in lines if line.strip()) * RED_LINE_DENSITY))
|
|
122
|
+
if markers > budget:
|
|
123
|
+
advisories.append(
|
|
124
|
+
f"{markers} red-line markers exceed the budget of {budget}. Keep a red line only when an "
|
|
125
|
+
"observed failure backs it, and delete the rest."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 2. Reasoned: an emphatic word carries its reason on the same or next line.
|
|
129
|
+
# 3. Once: an emphatic word is spent a single time.
|
|
130
|
+
seen: Counter[str] = Counter()
|
|
131
|
+
for number, line in enumerate(lines, start=1):
|
|
132
|
+
for word in re.findall(EMPHATIC_WORDS, line):
|
|
133
|
+
seen[word] += 1
|
|
134
|
+
window = " ".join(lines[number - 1 : number + 1])
|
|
135
|
+
if not re.search(REASON_WORDS, window, re.IGNORECASE):
|
|
136
|
+
advisories.append(
|
|
137
|
+
f"line {number}: emphatic '{word}' has no reason beside it. "
|
|
138
|
+
"State the consequence, or drop the emphasis."
|
|
139
|
+
)
|
|
140
|
+
for word, count in seen.items():
|
|
141
|
+
if count > 1:
|
|
142
|
+
advisories.append(f"emphatic '{word}' appears {count} times. Spend emphasis once, on the rule that causes real damage.")
|
|
143
|
+
|
|
144
|
+
return advisories
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Utility functions for validators."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_frontmatter(text: str) -> dict[str, str] | None:
|
|
7
|
+
"""Parse YAML frontmatter from markdown text using regex."""
|
|
8
|
+
if not text.startswith("---"):
|
|
9
|
+
return None
|
|
10
|
+
end = text.find("\n---", 3)
|
|
11
|
+
if end == -1:
|
|
12
|
+
return None
|
|
13
|
+
block = text[3:end].strip()
|
|
14
|
+
result: dict[str, str] = {}
|
|
15
|
+
current_key = None
|
|
16
|
+
current_val: list[str] = []
|
|
17
|
+
|
|
18
|
+
for line in block.splitlines():
|
|
19
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
20
|
+
continue
|
|
21
|
+
# Check for top-level key: value
|
|
22
|
+
match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line)
|
|
23
|
+
if match and not line.startswith(" "):
|
|
24
|
+
if current_key is not None:
|
|
25
|
+
result[current_key] = "\n".join(current_val).strip().strip("\"'")
|
|
26
|
+
current_key = match.group(1)
|
|
27
|
+
raw_val = match.group(2).strip()
|
|
28
|
+
current_val = [raw_val] if raw_val else []
|
|
29
|
+
elif current_key is not None:
|
|
30
|
+
current_val.append(line.strip())
|
|
31
|
+
|
|
32
|
+
if current_key is not None:
|
|
33
|
+
result[current_key] = "\n".join(current_val).strip().strip("\"'")
|
|
34
|
+
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_markdown_links(text: str) -> list[str]:
|
|
39
|
+
"""Extract local markdown link targets [label](target)."""
|
|
40
|
+
# Strip inline code and fenced code blocks
|
|
41
|
+
stripped = re.sub(r"```[\s\S]*?```", "", text)
|
|
42
|
+
stripped = re.sub(r"`[^`\n]+`", "", stripped)
|
|
43
|
+
# Match markdown links: [label](target)
|
|
44
|
+
matches = re.findall(r"\[(?:[^\]]*)\]\(([^)#\s]+)(?:#[^)]*)?\)", stripped)
|
|
45
|
+
# Filter out web URLs, mailto, etc.
|
|
46
|
+
local_links = [m for m in matches if not re.match(r"^[a-zA-Z]+://", m) and not m.startswith("mailto:")]
|
|
47
|
+
return local_links
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def strip_code(text: str) -> str:
|
|
51
|
+
"""Blank out fenced and inline code while keeping line numbers stable."""
|
|
52
|
+
stripped = re.sub(r"```[\s\S]*?```", lambda m: "\n" * m.group(0).count("\n"), text)
|
|
53
|
+
return re.sub(r"`[^`\n]+`", "", stripped)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_defensive_phrases(text: str, patterns: list[str]) -> list[str]:
|
|
57
|
+
"""Find defensive phrasing anti-patterns outside code blocks."""
|
|
58
|
+
stripped = strip_code(text)
|
|
59
|
+
found: list[str] = []
|
|
60
|
+
for pattern in patterns:
|
|
61
|
+
if re.search(pattern, stripped, re.IGNORECASE):
|
|
62
|
+
found.append(pattern)
|
|
63
|
+
return found
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_file_path_from_payload(payload: dict) -> str | None:
|
|
67
|
+
"""Extract the edited file path from the pi extension payload's tool_input.file_path."""
|
|
68
|
+
tool_input = payload.get("tool_input", {})
|
|
69
|
+
if isinstance(tool_input, dict) and tool_input.get("file_path"):
|
|
70
|
+
return str(tool_input["file_path"])
|
|
71
|
+
return None
|