@nklisch/pi-agile-workflow 0.15.3
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prompt-gated agile-workflow context for Codex and Claude plugin hooks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
try: # POSIX advisory file locking; absent on some platforms (e.g. Windows).
|
|
19
|
+
import fcntl
|
|
20
|
+
except ImportError: # pragma: no cover - exercised via monkeypatch in tests.
|
|
21
|
+
fcntl = None # type: ignore[assignment]
|
|
22
|
+
|
|
23
|
+
MAX_SESSIONS = 20
|
|
24
|
+
|
|
25
|
+
STRONG_ACTION_RE = re.compile(
|
|
26
|
+
r"\b("
|
|
27
|
+
r"agile-workflow|autopilot|drain|epicize|depends_on|work item"
|
|
28
|
+
r")\b",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
POLY_WORKFLOW_ACTION_RE = re.compile(r"\b(ready|blocked|release|deploy|gate|queue|done|convert|ideate)\b", re.IGNORECASE)
|
|
32
|
+
POLY_WORKFLOW_CONTEXT_RE = re.compile(r"\b(epic|feature|story|item|items|stage|workflow|agile-workflow|depends_on)\b", re.IGNORECASE)
|
|
33
|
+
ITEM_VERB_RE = re.compile(
|
|
34
|
+
r"\b(implement|fix|patch|design|scope|park|refactor|perf|optimi[sz]e|review|verdict|done)\b",
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
# A one-word imperative like "implement" is a high-intent workflow command in
|
|
38
|
+
# an agile-workflow-enabled repo. Earlier gating required an explicit noun
|
|
39
|
+
# ("implement item"), which was too conservative: the hook is already gated by
|
|
40
|
+
# `.work/CONVENTIONS.md`, and principles capsules are small + per-session deduped.
|
|
41
|
+
SHORT_WORKFLOW_COMMAND_RE = re.compile(
|
|
42
|
+
r"^\s*(implement|fix|patch|design|scope|park|refactor|review|done|release|deploy|gate|convert|ideate|epicize|autopilot|perf|optimi[sz]e)\s*[.!?]*\s*$",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
WORKFLOW_NOUN_RE = re.compile(r"\b(epic|feature|story|item|items|backlog|ready|queue|stage|workflow)\b", re.IGNORECASE)
|
|
46
|
+
ITEM_REF_SHAPE_RE = re.compile(r"\b[a-z0-9]+-[a-z0-9][a-z0-9-]*\b", re.IGNORECASE)
|
|
47
|
+
REVIEW_STAGE_RE = re.compile(r"\b(at|in)\s+review\b|\breview\s+(queue|everything|all)\b", re.IGNORECASE)
|
|
48
|
+
SLASH_RE = re.compile(
|
|
49
|
+
r"(^|\s)/("
|
|
50
|
+
r"agile-workflow:[a-z0-9-]+|"
|
|
51
|
+
r"autopilot|review|implement|implement-orchestrator|fix|scope|park|"
|
|
52
|
+
r"release-deploy|gate-[a-z0-9-]+|epic-design|feature-design|"
|
|
53
|
+
r"refactor-design|perf-design|convert|ideate|epicize"
|
|
54
|
+
r")\b",
|
|
55
|
+
re.IGNORECASE,
|
|
56
|
+
)
|
|
57
|
+
SKILL_MENTION_RE = re.compile(r"(^|\s)\$agile-workflow(:[a-z0-9-]+)?\b", re.IGNORECASE)
|
|
58
|
+
EXPLAIN_RE = re.compile(
|
|
59
|
+
r"^\s*(explain|describe|what is|what are|how does|why|eli5|simplify)\b",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
QUEUE_QUERY_RE = re.compile(
|
|
63
|
+
r"\b(what'?s|what is|show|list|next)\s+(ready|blocked|in review|at review)\b|"
|
|
64
|
+
r"\b(what'?s|what is|which|show|list|next)\b.*\b(backlog|queue|item|items|story|feature|epic)\b",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
DEFAULT_RULES_MAX_BYTES = 12000
|
|
69
|
+
|
|
70
|
+
CAPSULES = {
|
|
71
|
+
"code_design": {
|
|
72
|
+
"title": "Code-design capsule",
|
|
73
|
+
"triggers": re.compile(
|
|
74
|
+
r"\b(design|implement|fix|patch|scope|refactor|perf|optimi[sz]e|story|feature|bug)\b",
|
|
75
|
+
re.IGNORECASE,
|
|
76
|
+
),
|
|
77
|
+
"text": [
|
|
78
|
+
"Ports & Adapters: domain logic stays independent of DB/filesystem/HTTP/time/randomness.",
|
|
79
|
+
"Single Source of Truth: growing variant sets have one registry; types, validation, routing, and display derive from it.",
|
|
80
|
+
"Generated Contracts: boundary types come from schema/router/DB inference or generation instead of hand copies.",
|
|
81
|
+
"Fail Fast: unknown input is validated at system boundaries and internal preconditions are asserted early.",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
"dispatch_economy": {
|
|
85
|
+
"title": "Dispatch-economy capsule",
|
|
86
|
+
"triggers": re.compile(
|
|
87
|
+
r"\b(autopilot|drain|implement-orchestrator|parallel|wave|explore|audit|gate|bundle|fan-?out)\b",
|
|
88
|
+
re.IGNORECASE,
|
|
89
|
+
),
|
|
90
|
+
"text": [
|
|
91
|
+
"Local probes with rg/read/work-view come before read-only agents.",
|
|
92
|
+
"Agents are for breadth, isolation, fresh judgment, or independent write ownership.",
|
|
93
|
+
"Parallelism follows ownership and dependency layers rather than item count.",
|
|
94
|
+
"Dispatch rationale belongs in run notes or the item body when it affects bundling or wave width.",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
"advisory_review": {
|
|
98
|
+
"title": "Advisory-review capsule",
|
|
99
|
+
"triggers": re.compile(
|
|
100
|
+
r"\b(review|verdict|complete|done|risky|architecture|epic|feature|autopilot|final review)\b",
|
|
101
|
+
re.IGNORECASE,
|
|
102
|
+
),
|
|
103
|
+
"text": [
|
|
104
|
+
"Cross-model peer review applies only when a different model class is available.",
|
|
105
|
+
"Same-model review uses a fresh-context sub-agent rather than inline self-review.",
|
|
106
|
+
"Stories fast-advance on verification; features and epics get deeper review.",
|
|
107
|
+
"Advisory review is non-blocking during design, but final autopilot completion needs a successful review path.",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_payload() -> dict[str, Any]:
|
|
114
|
+
raw = sys.stdin.read()
|
|
115
|
+
if not raw.strip():
|
|
116
|
+
return {}
|
|
117
|
+
try:
|
|
118
|
+
value = json.loads(raw)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
return {}
|
|
121
|
+
return value if isinstance(value, dict) else {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_substrate_root(cwd: str | None) -> Path | None:
|
|
125
|
+
start = Path(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()).resolve()
|
|
126
|
+
for candidate in (start, *start.parents):
|
|
127
|
+
if (candidate / ".work" / "CONVENTIONS.md").is_file():
|
|
128
|
+
return candidate
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def state_path(root: Path) -> Path:
|
|
133
|
+
plugin_data = os.environ.get("PLUGIN_DATA") or os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
134
|
+
digest = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:16]
|
|
135
|
+
name = re.sub(r"[^a-zA-Z0-9_.-]+", "-", root.name).strip("-") or "workspace"
|
|
136
|
+
if plugin_data:
|
|
137
|
+
return Path(plugin_data) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
|
|
138
|
+
state_home = os.environ.get("XDG_STATE_HOME")
|
|
139
|
+
if state_home:
|
|
140
|
+
return Path(state_home) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
|
|
141
|
+
home = Path.home()
|
|
142
|
+
if str(home) and str(home) != "/":
|
|
143
|
+
return home / ".local" / "state" / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
|
|
144
|
+
return Path(tempfile.gettempdir()) / "agile-workflow" / f"{name}-{digest}" / "principles-hook-state.json"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_state(root: Path) -> dict[str, Any]:
|
|
148
|
+
path = state_path(root)
|
|
149
|
+
try:
|
|
150
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
151
|
+
value = json.load(fh)
|
|
152
|
+
except (OSError, json.JSONDecodeError):
|
|
153
|
+
return {"version": 1, "sessions": {}}
|
|
154
|
+
if not isinstance(value, dict):
|
|
155
|
+
return {"version": 1, "sessions": {}}
|
|
156
|
+
value.setdefault("version", 1)
|
|
157
|
+
value.setdefault("sessions", {})
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_state(root: Path, state: dict[str, Any]) -> None:
|
|
162
|
+
path = state_path(root)
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
# Per-writer unique temp path (pid + random token) so overlapping writers do
|
|
165
|
+
# not clobber each other's temp before the atomic os.replace. A fixed
|
|
166
|
+
# ".json.tmp" would race: two processes writing it concurrently could rename
|
|
167
|
+
# a half-written or interleaved file into place.
|
|
168
|
+
token = os.urandom(8).hex()
|
|
169
|
+
tmp = path.with_suffix(f".{os.getpid()}.{token}.tmp")
|
|
170
|
+
try:
|
|
171
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
172
|
+
json.dump(state, fh, indent=2, sort_keys=True)
|
|
173
|
+
fh.write("\n")
|
|
174
|
+
os.replace(tmp, path)
|
|
175
|
+
except OSError:
|
|
176
|
+
# Fail open: a failed save must never crash the hook. Best-effort cleanup
|
|
177
|
+
# of our uniquely-named temp so we don't leave debris behind.
|
|
178
|
+
with contextlib.suppress(OSError):
|
|
179
|
+
tmp.unlink()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@contextlib.contextmanager
|
|
183
|
+
def _state_lock(root: Path) -> Iterator[None]:
|
|
184
|
+
"""Serialize the state-file read-modify-write across concurrent hook procs.
|
|
185
|
+
|
|
186
|
+
Holds an advisory ``fcntl.flock`` on a sibling ``.lock`` file for the whole
|
|
187
|
+
load -> mutate -> save cycle so two processes cannot lose each other's
|
|
188
|
+
session entries (whole-file last-writer-wins). Degrades gracefully:
|
|
189
|
+
|
|
190
|
+
- Where ``fcntl`` is unavailable (import-guarded above), this is a no-op and
|
|
191
|
+
we fall back to the prior best-effort behavior.
|
|
192
|
+
- Any locking failure (cannot open/create the lock file, flock errors) is
|
|
193
|
+
swallowed — the hook must stay fail-open and still exit 0. We never block
|
|
194
|
+
the hook on lock acquisition errors.
|
|
195
|
+
"""
|
|
196
|
+
if fcntl is None:
|
|
197
|
+
yield
|
|
198
|
+
return
|
|
199
|
+
lock_path = state_path(root).with_suffix(".lock")
|
|
200
|
+
handle = None
|
|
201
|
+
try:
|
|
202
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
handle = lock_path.open("a+")
|
|
204
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
205
|
+
except OSError:
|
|
206
|
+
# Could not acquire the lock — proceed unlocked rather than block/crash.
|
|
207
|
+
if handle is not None:
|
|
208
|
+
with contextlib.suppress(OSError):
|
|
209
|
+
handle.close()
|
|
210
|
+
yield
|
|
211
|
+
return
|
|
212
|
+
try:
|
|
213
|
+
yield
|
|
214
|
+
finally:
|
|
215
|
+
with contextlib.suppress(OSError):
|
|
216
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
217
|
+
with contextlib.suppress(OSError):
|
|
218
|
+
handle.close()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def session_entry(state: dict[str, Any], session_id: str) -> dict[str, Any]:
|
|
222
|
+
sessions = state.setdefault("sessions", {})
|
|
223
|
+
entry = sessions.setdefault(session_id, {})
|
|
224
|
+
entry.setdefault("epoch", 0)
|
|
225
|
+
entry.setdefault("seen", {})
|
|
226
|
+
entry["last_seen"] = time.time()
|
|
227
|
+
if len(sessions) > MAX_SESSIONS:
|
|
228
|
+
ordered = sorted(sessions.items(), key=lambda item: int(item[1].get("last_seen") or 0))
|
|
229
|
+
for old_session, _ in ordered[: len(sessions) - MAX_SESSIONS]:
|
|
230
|
+
if old_session != session_id:
|
|
231
|
+
sessions.pop(old_session, None)
|
|
232
|
+
return entry
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def bump_epoch(root: Path, payload: dict[str, Any]) -> None:
|
|
236
|
+
session_id = str(payload.get("session_id") or "unknown")
|
|
237
|
+
event = str(payload.get("hook_event_name") or "")
|
|
238
|
+
source = str(payload.get("source") or payload.get("trigger") or "")
|
|
239
|
+
with _state_lock(root):
|
|
240
|
+
state = load_state(root)
|
|
241
|
+
entry = session_entry(state, session_id)
|
|
242
|
+
if event == "SessionStart" and source in {"startup", "clear", ""}:
|
|
243
|
+
entry["epoch"] = 0
|
|
244
|
+
entry["seen"] = {}
|
|
245
|
+
elif event == "PostCompact" or source in {"resume", "compact"}:
|
|
246
|
+
entry["epoch"] = int(entry.get("epoch") or 0) + 1
|
|
247
|
+
entry["seen"] = {}
|
|
248
|
+
save_state(root, state)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def output_context(event_name: str, text: str) -> None:
|
|
252
|
+
if not text.strip():
|
|
253
|
+
return
|
|
254
|
+
print(
|
|
255
|
+
json.dumps(
|
|
256
|
+
{
|
|
257
|
+
"hookSpecificOutput": {
|
|
258
|
+
"hookEventName": event_name,
|
|
259
|
+
"additionalContext": text,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def frontmatter(path: Path) -> dict[str, str]:
|
|
267
|
+
try:
|
|
268
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
269
|
+
except OSError:
|
|
270
|
+
return {}
|
|
271
|
+
if not lines or lines[0].strip() != "---":
|
|
272
|
+
return {}
|
|
273
|
+
fields: dict[str, str] = {}
|
|
274
|
+
for line in lines[1:]:
|
|
275
|
+
if line.strip() == "---":
|
|
276
|
+
break
|
|
277
|
+
if ":" not in line or line.startswith((" ", "\t", "-")):
|
|
278
|
+
continue
|
|
279
|
+
key, value = line.split(":", 1)
|
|
280
|
+
fields[key.strip()] = value.strip().strip('"').strip("'")
|
|
281
|
+
return fields
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def item_files(root: Path) -> list[Path]:
|
|
285
|
+
work = root / ".work"
|
|
286
|
+
files: list[Path] = []
|
|
287
|
+
for rel in ("active", "backlog", "releases", "archive"):
|
|
288
|
+
base = work / rel
|
|
289
|
+
if base.exists():
|
|
290
|
+
files.extend(sorted(base.rglob("*.md")))
|
|
291
|
+
return files
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def item_index(root: Path) -> dict[str, Path]:
|
|
295
|
+
index: dict[str, Path] = {}
|
|
296
|
+
for path in item_files(root):
|
|
297
|
+
fields = frontmatter(path)
|
|
298
|
+
item_id = fields.get("id") or path.stem
|
|
299
|
+
if item_id:
|
|
300
|
+
index[item_id] = path
|
|
301
|
+
return index
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def matched_item_ids(prompt: str, ids: set[str]) -> set[str]:
|
|
305
|
+
found: set[str] = set()
|
|
306
|
+
for item_id in ids:
|
|
307
|
+
pattern = re.compile(rf"(?<![a-z0-9-]){re.escape(item_id.lower())}(?![a-z0-9-])")
|
|
308
|
+
if pattern.search(prompt.lower()):
|
|
309
|
+
found.add(item_id)
|
|
310
|
+
return found
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cheap_action_candidate(prompt: str) -> bool:
|
|
314
|
+
if EXPLAIN_RE.search(prompt) and not (QUEUE_QUERY_RE.search(prompt) or ITEM_REF_SHAPE_RE.search(prompt)):
|
|
315
|
+
return False
|
|
316
|
+
if SLASH_RE.search(prompt) or SKILL_MENTION_RE.search(prompt):
|
|
317
|
+
return True
|
|
318
|
+
if SHORT_WORKFLOW_COMMAND_RE.search(prompt):
|
|
319
|
+
return True
|
|
320
|
+
if QUEUE_QUERY_RE.search(prompt):
|
|
321
|
+
return True
|
|
322
|
+
if STRONG_ACTION_RE.search(prompt):
|
|
323
|
+
return True
|
|
324
|
+
if ITEM_REF_SHAPE_RE.search(prompt):
|
|
325
|
+
return True
|
|
326
|
+
if POLY_WORKFLOW_ACTION_RE.search(prompt) and POLY_WORKFLOW_CONTEXT_RE.search(prompt):
|
|
327
|
+
return True
|
|
328
|
+
return bool(
|
|
329
|
+
ITEM_VERB_RE.search(prompt)
|
|
330
|
+
and (WORKFLOW_NOUN_RE.search(prompt) or ITEM_REF_SHAPE_RE.search(prompt) or REVIEW_STAGE_RE.search(prompt))
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def is_actionable(prompt: str, matched_ids: set[str]) -> bool:
|
|
335
|
+
if matched_ids:
|
|
336
|
+
return True
|
|
337
|
+
if SLASH_RE.search(prompt) or SKILL_MENTION_RE.search(prompt):
|
|
338
|
+
return True
|
|
339
|
+
if SHORT_WORKFLOW_COMMAND_RE.search(prompt):
|
|
340
|
+
return True
|
|
341
|
+
if QUEUE_QUERY_RE.search(prompt) or STRONG_ACTION_RE.search(prompt):
|
|
342
|
+
return True
|
|
343
|
+
if POLY_WORKFLOW_ACTION_RE.search(prompt) and POLY_WORKFLOW_CONTEXT_RE.search(prompt):
|
|
344
|
+
return True
|
|
345
|
+
return bool(
|
|
346
|
+
ITEM_VERB_RE.search(prompt)
|
|
347
|
+
and (WORKFLOW_NOUN_RE.search(prompt) or REVIEW_STAGE_RE.search(prompt))
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def plugin_root() -> Path | None:
|
|
352
|
+
pr = os.environ.get("PLUGIN_ROOT") or os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
353
|
+
return Path(pr) if pr else None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def is_codex_hook_environment() -> bool:
|
|
357
|
+
return bool(os.environ.get("PLUGIN_ROOT") or os.environ.get("PLUGIN_DATA"))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def plugin_version(pr: Path) -> str | None:
|
|
361
|
+
try:
|
|
362
|
+
data = json.loads((pr / ".claude-plugin" / "plugin.json").read_text("utf-8"))
|
|
363
|
+
except (OSError, json.JSONDecodeError):
|
|
364
|
+
return None
|
|
365
|
+
if not isinstance(data, dict):
|
|
366
|
+
return None
|
|
367
|
+
version = data.get("version")
|
|
368
|
+
return version if isinstance(version, str) and version else None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def installed_version(root: Path) -> str | None:
|
|
372
|
+
work_view = root / ".work" / "bin" / "work-view"
|
|
373
|
+
if not work_view.is_file() or not os.access(work_view, os.X_OK):
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
result = subprocess.run(
|
|
377
|
+
[str(work_view), "--version"],
|
|
378
|
+
cwd=str(root),
|
|
379
|
+
text=True,
|
|
380
|
+
stdout=subprocess.PIPE,
|
|
381
|
+
stderr=subprocess.DEVNULL,
|
|
382
|
+
timeout=5,
|
|
383
|
+
check=False,
|
|
384
|
+
)
|
|
385
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
386
|
+
return None
|
|
387
|
+
if result.returncode != 0:
|
|
388
|
+
return None
|
|
389
|
+
out = result.stdout.strip()
|
|
390
|
+
return out.split()[-1] if out.startswith("work-view ") else None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def run_installer(root: Path, pr: Path) -> None:
|
|
394
|
+
installer = pr / "scripts" / "install-work-view.sh"
|
|
395
|
+
if not installer.is_file():
|
|
396
|
+
return
|
|
397
|
+
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
|
|
398
|
+
subprocess.run(
|
|
399
|
+
["bash", str(installer)],
|
|
400
|
+
cwd=str(root),
|
|
401
|
+
env={**os.environ, "PLUGIN_ROOT": str(pr)},
|
|
402
|
+
stdout=subprocess.DEVNULL,
|
|
403
|
+
stderr=subprocess.DEVNULL,
|
|
404
|
+
timeout=20,
|
|
405
|
+
check=False,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def self_heal_work_view(root: Path, event: str) -> None:
|
|
410
|
+
try:
|
|
411
|
+
pr = plugin_root()
|
|
412
|
+
if pr is None:
|
|
413
|
+
return
|
|
414
|
+
work_view = root / ".work" / "bin" / "work-view"
|
|
415
|
+
present = work_view.is_file() and os.access(work_view, os.X_OK)
|
|
416
|
+
if event == "UserPromptSubmit":
|
|
417
|
+
if not present:
|
|
418
|
+
run_installer(root, pr)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if not present:
|
|
422
|
+
run_installer(root, pr)
|
|
423
|
+
return
|
|
424
|
+
want = plugin_version(pr)
|
|
425
|
+
if want is None:
|
|
426
|
+
return
|
|
427
|
+
if installed_version(root) != want:
|
|
428
|
+
run_installer(root, pr)
|
|
429
|
+
except Exception:
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def capsule_keys(prompt: str, matched_ids_set: set[str], index: dict[str, Path]) -> list[str]:
|
|
434
|
+
keys = [key for key, spec in CAPSULES.items() if spec["triggers"].search(prompt)]
|
|
435
|
+
if matched_ids_set:
|
|
436
|
+
for item_id in matched_ids_set:
|
|
437
|
+
fields = frontmatter(index.get(item_id, Path()))
|
|
438
|
+
kind = fields.get("kind")
|
|
439
|
+
stage = fields.get("stage")
|
|
440
|
+
if stage in {"drafting", "implementing"} and "code_design" not in keys:
|
|
441
|
+
keys.append("code_design")
|
|
442
|
+
if stage == "review" and "advisory_review" not in keys:
|
|
443
|
+
keys.append("advisory_review")
|
|
444
|
+
if kind in {"feature", "epic"} and "advisory_review" not in keys:
|
|
445
|
+
keys.append("advisory_review")
|
|
446
|
+
return keys
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def unseen_capsules(root: Path, payload: dict[str, Any], keys: list[str]) -> list[str]:
|
|
450
|
+
if not keys:
|
|
451
|
+
return []
|
|
452
|
+
session_id = str(payload.get("session_id") or "unknown")
|
|
453
|
+
with _state_lock(root):
|
|
454
|
+
state = load_state(root)
|
|
455
|
+
entry = session_entry(state, session_id)
|
|
456
|
+
epoch = int(entry.get("epoch") or 0)
|
|
457
|
+
seen = entry.setdefault("seen", {})
|
|
458
|
+
unseen = [key for key in keys if seen.get(key) != epoch]
|
|
459
|
+
for key in unseen:
|
|
460
|
+
seen[key] = epoch
|
|
461
|
+
save_state(root, state)
|
|
462
|
+
return unseen
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def format_capsules(keys: list[str]) -> str:
|
|
466
|
+
if not keys:
|
|
467
|
+
return ""
|
|
468
|
+
lines = ["## Agile Workflow Principles"]
|
|
469
|
+
for key in keys:
|
|
470
|
+
spec = CAPSULES[key]
|
|
471
|
+
lines.append(f"{spec['title']}:")
|
|
472
|
+
lines.extend(f"- {line}" for line in spec["text"])
|
|
473
|
+
return "\n".join(lines)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def rules_config(root: Path) -> tuple[bool, int]:
|
|
477
|
+
"""Parse `.work/CONVENTIONS.md` for the rules-loader knobs.
|
|
478
|
+
|
|
479
|
+
`rules_context: on|off` (default on) and `rules_context_max_bytes: <int>`
|
|
480
|
+
(default DEFAULT_RULES_MAX_BYTES). Tolerant: any read/parse failure returns
|
|
481
|
+
the enabled defaults so a malformed CONVENTIONS never silences rules.
|
|
482
|
+
"""
|
|
483
|
+
enabled = True
|
|
484
|
+
max_bytes = DEFAULT_RULES_MAX_BYTES
|
|
485
|
+
try:
|
|
486
|
+
text = (root / ".work" / "CONVENTIONS.md").read_text(encoding="utf-8")
|
|
487
|
+
except (OSError, UnicodeDecodeError):
|
|
488
|
+
return enabled, max_bytes
|
|
489
|
+
for raw in text.splitlines():
|
|
490
|
+
line = raw.strip().lstrip("-").strip()
|
|
491
|
+
# Anchor the value so prose like "rules_context: off disables injection"
|
|
492
|
+
# in a CONVENTIONS example does not silently flip the flag.
|
|
493
|
+
flag = re.match(
|
|
494
|
+
r"rules_context\s*:\s*(on|off|true|false|yes|no|0|1)\s*$", line, re.IGNORECASE
|
|
495
|
+
)
|
|
496
|
+
if flag:
|
|
497
|
+
enabled = flag.group(1).lower() not in {"off", "false", "no", "0"}
|
|
498
|
+
continue
|
|
499
|
+
cap = re.match(r"rules_context_max_bytes\s*:\s*(\d+)\s*$", line, re.IGNORECASE)
|
|
500
|
+
if cap:
|
|
501
|
+
value = int(cap.group(1))
|
|
502
|
+
if value > 0: # 0/invalid keeps the default; use `rules_context: off` to disable
|
|
503
|
+
max_bytes = value
|
|
504
|
+
return enabled, max_bytes
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def read_rules_dir(root: Path, max_bytes: int) -> tuple[str, str]:
|
|
508
|
+
"""Read `<root>/.agents/rules/*.md` (sorted) into one injectable block.
|
|
509
|
+
|
|
510
|
+
Returns ``(text, sha256)`` where the hash is over the UNtruncated
|
|
511
|
+
concatenation so any edit re-injects. Truncates the emitted text at
|
|
512
|
+
``max_bytes`` with a notice. Returns ``("", "")`` when the dir is absent or
|
|
513
|
+
has no readable markdown content.
|
|
514
|
+
"""
|
|
515
|
+
rules_dir = root / ".agents" / "rules"
|
|
516
|
+
if not rules_dir.is_dir():
|
|
517
|
+
return "", ""
|
|
518
|
+
chunks: list[str] = []
|
|
519
|
+
for path in sorted(rules_dir.glob("*.md")):
|
|
520
|
+
try:
|
|
521
|
+
body = path.read_text(encoding="utf-8").strip()
|
|
522
|
+
except OSError:
|
|
523
|
+
continue
|
|
524
|
+
if body:
|
|
525
|
+
chunks.append(body)
|
|
526
|
+
if not chunks:
|
|
527
|
+
return "", ""
|
|
528
|
+
body = "\n\n".join(chunks)
|
|
529
|
+
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
530
|
+
if max_bytes and len(body.encode("utf-8")) > max_bytes:
|
|
531
|
+
clipped = body.encode("utf-8")[:max_bytes].decode("utf-8", "ignore").rstrip()
|
|
532
|
+
body = clipped + "\n\n(.agents/rules truncated — read the files for full content)"
|
|
533
|
+
return "## Project Rules (.agents/rules/)\n" + body, digest
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def rules_unseen(root: Path, payload: dict[str, Any], content_hash: str) -> bool:
|
|
537
|
+
"""True iff (epoch, content_hash) has not yet been injected this session.
|
|
538
|
+
|
|
539
|
+
Reuses the epoch state; stores ``seen['rules'] = f'{epoch}:{hash}'`` so rules
|
|
540
|
+
re-inject after a PostCompact epoch bump or when `.agents/rules/` content
|
|
541
|
+
changes, but only once per (epoch, content) otherwise.
|
|
542
|
+
"""
|
|
543
|
+
session_id = str(payload.get("session_id") or "unknown")
|
|
544
|
+
with _state_lock(root):
|
|
545
|
+
state = load_state(root)
|
|
546
|
+
entry = session_entry(state, session_id)
|
|
547
|
+
marker = f"{int(entry.get('epoch') or 0)}:{content_hash}"
|
|
548
|
+
seen = entry.setdefault("seen", {})
|
|
549
|
+
if seen.get("rules") == marker:
|
|
550
|
+
return False
|
|
551
|
+
seen["rules"] = marker
|
|
552
|
+
save_state(root, state)
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def emit_rules(root: Path, payload: dict[str, Any], *, force: bool = False) -> str:
|
|
557
|
+
"""Return `.agents/rules/` context to inject, or "".
|
|
558
|
+
|
|
559
|
+
Claude/Codex SessionStart/PostCompact hooks dedup once per epoch via
|
|
560
|
+
``rules_unseen`` because hook output is host-managed context. Pi rebuilds the
|
|
561
|
+
system prompt every turn and has no hooks.json package surface, so its native
|
|
562
|
+
extension calls the synthetic ``PiBeforeAgentStart`` path with ``force=True``
|
|
563
|
+
to append the same rules block every turn without mutating hook state.
|
|
564
|
+
"""
|
|
565
|
+
enabled, max_bytes = rules_config(root)
|
|
566
|
+
if not enabled:
|
|
567
|
+
return ""
|
|
568
|
+
text, digest = read_rules_dir(root, max_bytes)
|
|
569
|
+
if not text:
|
|
570
|
+
return ""
|
|
571
|
+
if not force and not rules_unseen(root, payload, digest):
|
|
572
|
+
return ""
|
|
573
|
+
return text
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def main() -> int:
|
|
577
|
+
payload = load_payload()
|
|
578
|
+
event = str(payload.get("hook_event_name") or "")
|
|
579
|
+
root = find_substrate_root(payload.get("cwd"))
|
|
580
|
+
if root is None:
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
# Pi package parity adapter: Pi has native extension events rather than a
|
|
584
|
+
# hooks.json surface. The extension calls this synthetic path from
|
|
585
|
+
# before_agent_start so it can append the exact same .agents/rules block to
|
|
586
|
+
# Pi's rebuilt-per-turn system prompt without disturbing Claude/Codex epoch
|
|
587
|
+
# dedup state.
|
|
588
|
+
if event == "PiBeforeAgentStart":
|
|
589
|
+
output_context("SessionStart", emit_rules(root, payload, force=True))
|
|
590
|
+
return 0
|
|
591
|
+
|
|
592
|
+
# Primary rules firing: SessionStart / PostCompact emit `.agents/rules/`
|
|
593
|
+
# directly where the host supports hook-specific context. Codex accepts
|
|
594
|
+
# context on SessionStart but not PostCompact, so Codex PostCompact only
|
|
595
|
+
# bumps the epoch and leaves context injection to SessionStart compact.
|
|
596
|
+
if event in {"SessionStart", "PostCompact"}:
|
|
597
|
+
bump_epoch(root, payload)
|
|
598
|
+
self_heal_work_view(root, event)
|
|
599
|
+
if event == "PostCompact" and is_codex_hook_environment():
|
|
600
|
+
return 0
|
|
601
|
+
output_context(event, emit_rules(root, payload))
|
|
602
|
+
return 0
|
|
603
|
+
|
|
604
|
+
if event != "UserPromptSubmit":
|
|
605
|
+
return 0
|
|
606
|
+
|
|
607
|
+
prompt = str(payload.get("prompt") or "")
|
|
608
|
+
self_heal_work_view(root, event)
|
|
609
|
+
|
|
610
|
+
parts: list[str] = []
|
|
611
|
+
# Prompt-time output is limited to principles capsules behind the workflow
|
|
612
|
+
# gate. Queue state is available through explicit work-view/board commands.
|
|
613
|
+
if cheap_action_candidate(prompt):
|
|
614
|
+
index = item_index(root)
|
|
615
|
+
matched = matched_item_ids(prompt, set(index))
|
|
616
|
+
if is_actionable(prompt, matched):
|
|
617
|
+
capsule_text = format_capsules(
|
|
618
|
+
unseen_capsules(root, payload, capsule_keys(prompt, matched, index))
|
|
619
|
+
)
|
|
620
|
+
if capsule_text:
|
|
621
|
+
parts.append(capsule_text)
|
|
622
|
+
|
|
623
|
+
output_context(event, "\n\n".join(p for p in parts if p))
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
if __name__ == "__main__":
|
|
628
|
+
raise SystemExit(main())
|