pi-anchorloop 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -2
  3. package/README.zh-TW.md +64 -0
  4. package/hooks/validate_tool_use.py +88 -0
  5. package/hooks/validators/__init__.py +1 -0
  6. package/hooks/validators/constants.py +47 -0
  7. package/hooks/validators/durable_validator.py +89 -0
  8. package/hooks/validators/rule_validator.py +42 -0
  9. package/hooks/validators/skill_validator.py +144 -0
  10. package/hooks/validators/utils.py +71 -0
  11. package/package.json +44 -4
  12. package/pi/anchor-gate.ts +84 -0
  13. package/pi/decision-tool.ts +88 -0
  14. package/pi/second-thought.ts +242 -0
  15. package/pi/support/validators.ts +403 -0
  16. package/pi/ttsr.ts +516 -0
  17. package/pi/validate-tool-use.ts +27 -0
  18. package/skills/agent-terse/SKILL.md +46 -0
  19. package/skills/anchorloop/PRINCIPLES.md +34 -0
  20. package/skills/anchorloop/SKILL.md +76 -0
  21. package/skills/anchorloop/playbooks/autonomous-run.md +12 -0
  22. package/skills/anchorloop/playbooks/pause.md +10 -0
  23. package/skills/anchorloop/playbooks/pickup.md +11 -0
  24. package/skills/answering/SKILL.md +19 -0
  25. package/skills/answering/playbooks/compare.md +9 -0
  26. package/skills/answering/playbooks/explain.md +9 -0
  27. package/skills/coding/MINI-SDD.md +61 -0
  28. package/skills/coding/SKILL.md +27 -0
  29. package/skills/coding/playbooks/bug-fix.md +11 -0
  30. package/skills/coding/playbooks/feature.md +15 -0
  31. package/skills/coding/playbooks/perf.md +10 -0
  32. package/skills/coding/playbooks/prototype.md +14 -0
  33. package/skills/coding/playbooks/refactoring.md +12 -0
  34. package/skills/domain-modeling/ADR-FORMAT.md +47 -0
  35. package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  36. package/skills/domain-modeling/SKILL.md +48 -0
  37. package/skills/grilling/SKILL.md +30 -0
  38. package/skills/human-feedback/SKILL.md +83 -0
  39. package/skills/inspecting/SKILL.md +49 -0
  40. package/skills/inspecting/playbooks/diff-review.md +57 -0
  41. package/skills/inspecting/playbooks/qa-release.md +9 -0
  42. package/skills/investigating/SKILL.md +22 -0
  43. package/skills/investigating/playbooks/diagnose.md +9 -0
  44. package/skills/investigating/playbooks/research.md +9 -0
  45. package/skills/solid-loop/SKILL.md +50 -0
  46. package/skills/solid-loop/playbooks/apply-friction.md +15 -0
  47. package/skills/solid-loop/playbooks/author-skill.md +11 -0
  48. package/skills/solid-loop/playbooks/bootstrap.md +10 -0
  49. package/skills/solid-loop/references/template-skill.md +26 -0
  50. package/skills/writing/SKILL.md +16 -0
  51. package/skills/writing/playbooks/comments.md +10 -0
  52. package/skills/writing/playbooks/draft.md +10 -0
  53. 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
- # Temporary Holding Version
1
+ # pi-anchorloop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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