@pieerry/harness-kit 3.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.
- package/.claude/agents/product-manager.md +20 -0
- package/.claude/agents/staff-software-engineer.md +25 -0
- package/.claude/commands/product-manager/prd.md +31 -0
- package/.claude/commands/product-manager/prp.md +35 -0
- package/.claude/commands/product-manager/run.md +31 -0
- package/.claude/commands/sse/dev.md +47 -0
- package/.claude/commands/sse/plan.md +33 -0
- package/.claude/commands/sse/pr.md +43 -0
- package/.claude/commands/sse/run.md +39 -0
- package/.claude/commands/sse/test.md +38 -0
- package/.claude/hooks/status-line.sh +103 -0
- package/.claude/plugins/product-manager/README.md +120 -0
- package/.claude/plugins/product-manager/evals/prd-quality.md +88 -0
- package/.claude/plugins/product-manager/evals/prd-readiness.md +66 -0
- package/.claude/plugins/product-manager/evals/prp-context-readiness.md +51 -0
- package/.claude/plugins/product-manager/evals/prp-quality.md +88 -0
- package/.claude/plugins/product-manager/guides/examples/good-prd-example.md +121 -0
- package/.claude/plugins/product-manager/guides/examples/good-prp-example.md +128 -0
- package/.claude/plugins/product-manager/guides/pipeline.md +84 -0
- package/.claude/plugins/product-manager/guides/prd-guidelines.md +27 -0
- package/.claude/plugins/product-manager/guides/product-guidelines.md +75 -0
- package/.claude/plugins/product-manager/guides/prp-guidelines.md +64 -0
- package/.claude/plugins/product-manager/guides/templates/prd.md +89 -0
- package/.claude/plugins/product-manager/guides/templates/prp.md +98 -0
- package/.claude/plugins/product-manager/guides/writing-style.md +71 -0
- package/.claude/plugins/product-manager/hooks/post-eval-prd.sh +77 -0
- package/.claude/plugins/product-manager/hooks/post-eval-prp.sh +70 -0
- package/.claude/plugins/product-manager/hooks/post-write-prd.sh +56 -0
- package/.claude/plugins/product-manager/hooks/post-write-prp.sh +61 -0
- package/.claude/plugins/product-manager/hooks/pre-prp-check.sh +48 -0
- package/.claude/plugins/product-manager/outputs/.markers/.gitkeep +0 -0
- package/.claude/plugins/product-manager/scripts/confluence-publish.py +205 -0
- package/.claude/plugins/product-manager/scripts/link-validator.py +87 -0
- package/.claude/plugins/product-manager/scripts/sensor-runner.py +140 -0
- package/.claude/plugins/product-manager/scripts/token-phase.py +208 -0
- package/.claude/plugins/product-manager/sensors/prd-acceptance-criteria.md +39 -0
- package/.claude/plugins/product-manager/sensors/prd-structure.md +39 -0
- package/.claude/plugins/product-manager/sensors/prp-context-quality.md +42 -0
- package/.claude/plugins/product-manager/sensors/prp-links.md +24 -0
- package/.claude/plugins/product-manager/sensors/prp-structure.md +52 -0
- package/.claude/plugins/product-manager/skills/prd/SKILL.md +33 -0
- package/.claude/plugins/product-manager/skills/prp/SKILL.md +37 -0
- package/.claude/plugins/staff-software-engineer/README.md +90 -0
- package/.claude/plugins/staff-software-engineer/evals/plan-quality.md +48 -0
- package/.claude/plugins/staff-software-engineer/guides/coding-style.md +51 -0
- package/.claude/plugins/staff-software-engineer/guides/commit-style.md +44 -0
- package/.claude/plugins/staff-software-engineer/guides/conventions-override.md +79 -0
- package/.claude/plugins/staff-software-engineer/guides/pipeline.md +69 -0
- package/.claude/plugins/staff-software-engineer/hooks/post-eval-plan.sh +43 -0
- package/.claude/plugins/staff-software-engineer/hooks/post-write-plan.sh +49 -0
- package/.claude/plugins/staff-software-engineer/outputs/.markers/.gitkeep +0 -0
- package/.claude/plugins/staff-software-engineer/sensors/code-conventions.md +37 -0
- package/.claude/plugins/staff-software-engineer/sensors/plan-structure.md +37 -0
- package/.claude/plugins/staff-software-engineer/sensors/test-coverage.md +28 -0
- package/.claude/plugins/staff-software-engineer/skills/backend/SKILL.md +80 -0
- package/.claude/plugins/staff-software-engineer/skills/devops/SKILL.md +58 -0
- package/.claude/plugins/staff-software-engineer/skills/mobile/SKILL.md +52 -0
- package/.claude/plugins/staff-software-engineer/skills/web/SKILL.md +64 -0
- package/.claude/settings.local.json +61 -0
- package/CLAUDE.md +90 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/VERSION +1 -0
- package/bin/hk.js +141 -0
- package/context-library/README.md +38 -0
- package/context-library/business-info-template.md +39 -0
- package/context-library/decisions/README.md +3 -0
- package/context-library/example-prds/README.md +3 -0
- package/context-library/meetings/.gitkeep +0 -0
- package/context-library/metrics/.gitkeep +0 -0
- package/context-library/personal-context-template.md +31 -0
- package/context-library/research/.gitkeep +0 -0
- package/context-library/squads/README.md +32 -0
- package/context-library/strategy/README.md +3 -0
- package/package.json +43 -0
- package/setup/install.sh +154 -0
- package/setup/update.sh +17 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Publish a PRD or PRP to Confluence.
|
|
4
|
+
|
|
5
|
+
Reads credentials from environment:
|
|
6
|
+
- JIRA_USERNAME (email)
|
|
7
|
+
- JIRA_API_TOKEN (API token)
|
|
8
|
+
|
|
9
|
+
Targets the Confluence at https://YOUR-DOMAIN.atlassian.net.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
confluence-publish.py --artifact PATH.md --kind {prd|prp}
|
|
13
|
+
|
|
14
|
+
Exit 0 on success, 1 on failure. Stays silent on routine output, prints
|
|
15
|
+
errors to stderr.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import base64
|
|
20
|
+
import html
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
CONFLUENCE_BASE = "https://YOUR-DOMAIN.atlassian.net/wiki/rest/api/content"
|
|
29
|
+
SPACE_KEY = "TET1"
|
|
30
|
+
|
|
31
|
+
# Parent page IDs per kind. Override via env if needed.
|
|
32
|
+
PARENT_IDS = {
|
|
33
|
+
"prd": os.environ.get("CONFLUENCE_PARENT_PRD", "11351457795"),
|
|
34
|
+
"prp": os.environ.get("CONFLUENCE_PARENT_PRP", "11351457795"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def md_to_storage(md: str) -> str:
|
|
39
|
+
"""Minimal Markdown to Confluence storage XHTML.
|
|
40
|
+
Good enough for headings, paragraphs, fenced code, lists, tables.
|
|
41
|
+
For richer rendering, swap for a real converter later.
|
|
42
|
+
"""
|
|
43
|
+
lines = md.splitlines()
|
|
44
|
+
out: list[str] = []
|
|
45
|
+
in_code = False
|
|
46
|
+
in_list: str | None = None # 'ul' or 'ol'
|
|
47
|
+
in_table = False
|
|
48
|
+
|
|
49
|
+
def close_list():
|
|
50
|
+
nonlocal in_list
|
|
51
|
+
if in_list:
|
|
52
|
+
out.append(f"</{in_list}>")
|
|
53
|
+
in_list = None
|
|
54
|
+
|
|
55
|
+
def close_table():
|
|
56
|
+
nonlocal in_table
|
|
57
|
+
if in_table:
|
|
58
|
+
out.append("</tbody></table>")
|
|
59
|
+
in_table = False
|
|
60
|
+
|
|
61
|
+
for line in lines:
|
|
62
|
+
if line.startswith("```"):
|
|
63
|
+
close_list()
|
|
64
|
+
close_table()
|
|
65
|
+
if not in_code:
|
|
66
|
+
lang = line[3:].strip() or "none"
|
|
67
|
+
out.append(
|
|
68
|
+
f'<ac:structured-macro ac:name="code">'
|
|
69
|
+
f'<ac:parameter ac:name="language">{html.escape(lang)}</ac:parameter>'
|
|
70
|
+
f'<ac:plain-text-body><![CDATA['
|
|
71
|
+
)
|
|
72
|
+
in_code = True
|
|
73
|
+
else:
|
|
74
|
+
out.append("]]></ac:plain-text-body></ac:structured-macro>")
|
|
75
|
+
in_code = False
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if in_code:
|
|
79
|
+
out.append(line)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
h = re.match(r"^(#{1,6})\s+(.+)$", line)
|
|
83
|
+
if h:
|
|
84
|
+
close_list()
|
|
85
|
+
close_table()
|
|
86
|
+
level = len(h.group(1))
|
|
87
|
+
out.append(f"<h{level}>{html.escape(h.group(2))}</h{level}>")
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if re.match(r"^[-*]\s+", line):
|
|
91
|
+
close_table()
|
|
92
|
+
if in_list != "ul":
|
|
93
|
+
close_list()
|
|
94
|
+
out.append("<ul>")
|
|
95
|
+
in_list = "ul"
|
|
96
|
+
item = re.sub(r"^[-*]\s+", "", line)
|
|
97
|
+
out.append(f"<li>{html.escape(item)}</li>")
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if re.match(r"^\d+\.\s+", line):
|
|
101
|
+
close_table()
|
|
102
|
+
if in_list != "ol":
|
|
103
|
+
close_list()
|
|
104
|
+
out.append("<ol>")
|
|
105
|
+
in_list = "ol"
|
|
106
|
+
item = re.sub(r"^\d+\.\s+", "", line)
|
|
107
|
+
out.append(f"<li>{html.escape(item)}</li>")
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if line.startswith("|") and "|" in line[1:]:
|
|
111
|
+
close_list()
|
|
112
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
113
|
+
if not in_table:
|
|
114
|
+
out.append("<table><tbody>")
|
|
115
|
+
in_table = True
|
|
116
|
+
out.append(
|
|
117
|
+
"<tr>"
|
|
118
|
+
+ "".join(f"<th>{html.escape(c)}</th>" for c in cells)
|
|
119
|
+
+ "</tr>"
|
|
120
|
+
)
|
|
121
|
+
elif all(re.fullmatch(r":?-+:?", c) for c in cells):
|
|
122
|
+
# separator row, skip
|
|
123
|
+
pass
|
|
124
|
+
else:
|
|
125
|
+
out.append(
|
|
126
|
+
"<tr>"
|
|
127
|
+
+ "".join(f"<td>{html.escape(c)}</td>" for c in cells)
|
|
128
|
+
+ "</tr>"
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# paragraph or blank
|
|
133
|
+
close_list()
|
|
134
|
+
close_table()
|
|
135
|
+
if line.strip():
|
|
136
|
+
out.append(f"<p>{html.escape(line)}</p>")
|
|
137
|
+
|
|
138
|
+
close_list()
|
|
139
|
+
close_table()
|
|
140
|
+
return "\n".join(out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def extract_title(md: str) -> str:
|
|
144
|
+
for line in md.splitlines():
|
|
145
|
+
m = re.match(r"^#\s+(.+)$", line)
|
|
146
|
+
if m:
|
|
147
|
+
return m.group(1).strip()
|
|
148
|
+
return "Untitled"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> int:
|
|
152
|
+
parser = argparse.ArgumentParser()
|
|
153
|
+
parser.add_argument("--artifact", required=True, type=Path)
|
|
154
|
+
parser.add_argument("--kind", required=True, choices=["prd", "prp"])
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
|
|
157
|
+
username = os.environ.get("JIRA_USERNAME")
|
|
158
|
+
token = os.environ.get("JIRA_API_TOKEN")
|
|
159
|
+
if not username or not token:
|
|
160
|
+
print("[confluence-publish] JIRA_USERNAME/JIRA_API_TOKEN not set", file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
|
|
163
|
+
md = args.artifact.read_text(encoding="utf-8")
|
|
164
|
+
title = extract_title(md)
|
|
165
|
+
storage = md_to_storage(md)
|
|
166
|
+
|
|
167
|
+
payload = {
|
|
168
|
+
"type": "page",
|
|
169
|
+
"title": title,
|
|
170
|
+
"ancestors": [{"id": PARENT_IDS[args.kind]}],
|
|
171
|
+
"space": {"key": SPACE_KEY},
|
|
172
|
+
"body": {"storage": {"value": storage, "representation": "storage"}},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
auth = base64.b64encode(f"{username}:{token}".encode()).decode()
|
|
176
|
+
req = urllib.request.Request(
|
|
177
|
+
CONFLUENCE_BASE,
|
|
178
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
179
|
+
method="POST",
|
|
180
|
+
headers={
|
|
181
|
+
"Authorization": f"Basic {auth}",
|
|
182
|
+
"Content-Type": "application/json",
|
|
183
|
+
"Accept": "application/json",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
189
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
190
|
+
page_url = data.get("_links", {}).get("base", "") + data.get(
|
|
191
|
+
"_links", {}
|
|
192
|
+
).get("webui", "")
|
|
193
|
+
print(f"[confluence-publish] published: {page_url}", file=sys.stderr)
|
|
194
|
+
return 0
|
|
195
|
+
except urllib.error.HTTPError as e:
|
|
196
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
197
|
+
print(f"[confluence-publish] HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
|
198
|
+
return 1
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"[confluence-publish] error: {e}", file=sys.stderr)
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
sys.exit(main())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PRP link validator. Verifies that:
|
|
4
|
+
- Source PRD link points to an existing file
|
|
5
|
+
- Inline code paths (looking like file paths) end in a known extension
|
|
6
|
+
- HTTP URLs are syntactically valid
|
|
7
|
+
- No localhost URLs are pinned as references
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
link-validator.py --artifact PRP.md --repo-root /path/to/repo
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
KNOWN_EXTS = {
|
|
19
|
+
"java", "kt", "ts", "tsx", "js", "jsx", "vue",
|
|
20
|
+
"py", "sql", "yaml", "yml", "md", "sh", "gradle",
|
|
21
|
+
"xml", "json", "properties",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
parser = argparse.ArgumentParser()
|
|
27
|
+
parser.add_argument("--artifact", required=True, type=Path)
|
|
28
|
+
parser.add_argument("--repo-root", required=True, type=Path)
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
|
|
31
|
+
if not args.artifact.exists():
|
|
32
|
+
print(f"[link-validator] artifact not found: {args.artifact}", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
text = args.artifact.read_text(encoding="utf-8")
|
|
36
|
+
failures: list[str] = []
|
|
37
|
+
|
|
38
|
+
# 1) Source PRD must exist
|
|
39
|
+
m = re.search(r"\*\*Source PRD:\*\*\s+`?([^\s`]+)`?", text)
|
|
40
|
+
if m:
|
|
41
|
+
prd_rel = m.group(1).strip()
|
|
42
|
+
candidates = [
|
|
43
|
+
args.repo_root / prd_rel,
|
|
44
|
+
args.artifact.parent / prd_rel,
|
|
45
|
+
args.repo_root / ".claude/plugins/product-manager" / prd_rel,
|
|
46
|
+
]
|
|
47
|
+
if not any(c.exists() for c in candidates):
|
|
48
|
+
failures.append(f"Source PRD path does not resolve: {prd_rel}")
|
|
49
|
+
else:
|
|
50
|
+
failures.append("missing **Source PRD:** field")
|
|
51
|
+
|
|
52
|
+
# 2) Inline code-spans that look like paths must have known extensions
|
|
53
|
+
for span in re.finditer(r"`([^`\s]+/[^`\s]+)`", text):
|
|
54
|
+
path = span.group(1)
|
|
55
|
+
# strip :line suffix if present
|
|
56
|
+
path_clean = re.sub(r":\d+$", "", path)
|
|
57
|
+
ext = path_clean.rsplit(".", 1)[-1] if "." in path_clean else ""
|
|
58
|
+
if not ext or ext.lower() not in KNOWN_EXTS:
|
|
59
|
+
# Allow URLs and confluence-ish paths
|
|
60
|
+
if path.startswith("http") or "/wiki/" in path:
|
|
61
|
+
continue
|
|
62
|
+
failures.append(f"path-like span has unknown/missing extension: `{path}`")
|
|
63
|
+
|
|
64
|
+
# 3) Localhost URLs not allowed
|
|
65
|
+
if re.search(r"https?://(localhost|127\.0\.0\.1)", text):
|
|
66
|
+
failures.append("localhost URL pinned in PRP; replace with stable reference")
|
|
67
|
+
|
|
68
|
+
# 4) GitHub blob/tree on moving branches → warn (treat as failure for handoff)
|
|
69
|
+
for m in re.finditer(
|
|
70
|
+
r"github\.com/[\w-]+/[\w.-]+/(blob|tree)/(main|master|develop)/", text
|
|
71
|
+
):
|
|
72
|
+
failures.append(
|
|
73
|
+
f"GitHub link uses moving branch ({m.group(2)}); use a commit SHA or tag"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if failures:
|
|
77
|
+
print(f"[link-validator] {args.artifact.name} FAILED ({len(failures)} issue(s)):", file=sys.stderr)
|
|
78
|
+
for f in failures:
|
|
79
|
+
print(f" - {f}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
print(f"[link-validator] {args.artifact.name} passed", file=sys.stderr)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Deterministic sensor runner.
|
|
4
|
+
|
|
5
|
+
Parses a markdown sensor file (looking for known sections like
|
|
6
|
+
"Required sections", "Forbidden tokens", "Markdown rules") and applies the
|
|
7
|
+
checks against an artifact file. Returns exit code 0 if all checks pass, 1 if
|
|
8
|
+
any blocking check fails.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
sensor-runner.py --sensor SENSOR.md --artifact ARTIFACT.md
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def section_body(text: str, heading: str) -> str | None:
|
|
21
|
+
"""Return the body of a `## heading` section, or None if not found."""
|
|
22
|
+
pattern = rf"^##+\s+{re.escape(heading)}\s*$"
|
|
23
|
+
lines = text.splitlines()
|
|
24
|
+
start = None
|
|
25
|
+
for i, line in enumerate(lines):
|
|
26
|
+
if re.match(pattern, line, re.IGNORECASE):
|
|
27
|
+
start = i + 1
|
|
28
|
+
break
|
|
29
|
+
if start is None:
|
|
30
|
+
return None
|
|
31
|
+
end = len(lines)
|
|
32
|
+
for i in range(start, len(lines)):
|
|
33
|
+
if re.match(r"^##+\s+\S", lines[i]):
|
|
34
|
+
end = i
|
|
35
|
+
break
|
|
36
|
+
return "\n".join(lines[start:end])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bullets(body: str) -> list[str]:
|
|
40
|
+
return [
|
|
41
|
+
m.group(1).strip()
|
|
42
|
+
for m in re.finditer(r"^[-*]\s+(.+)$", body or "", re.MULTILINE)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_required_sections(sensor_md: str, artifact_text: str) -> list[str]:
|
|
47
|
+
body = section_body(sensor_md, "Required sections") or section_body(
|
|
48
|
+
sensor_md, "Required sections (all must be present)"
|
|
49
|
+
) or section_body(sensor_md, "Required sections (all must be present, in order)")
|
|
50
|
+
if not body:
|
|
51
|
+
return []
|
|
52
|
+
sections = bullets(body)
|
|
53
|
+
failures = []
|
|
54
|
+
for section in sections:
|
|
55
|
+
clean = section.split("(", 1)[0].strip()
|
|
56
|
+
pattern = rf"^##+\s+(\d+\)\s*)?{re.escape(clean)}\b"
|
|
57
|
+
if not re.search(pattern, artifact_text, re.MULTILINE | re.IGNORECASE):
|
|
58
|
+
failures.append(f"missing required section: '{clean}'")
|
|
59
|
+
return failures
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_forbidden_sections(sensor_md: str, artifact_text: str) -> list[str]:
|
|
63
|
+
body = section_body(sensor_md, "Forbidden sections")
|
|
64
|
+
if not body:
|
|
65
|
+
return []
|
|
66
|
+
failures = []
|
|
67
|
+
for section in bullets(body):
|
|
68
|
+
clean = section.split("(", 1)[0].strip()
|
|
69
|
+
pattern = rf"^##+\s+(\d+\)\s*)?{re.escape(clean)}\b"
|
|
70
|
+
if re.search(pattern, artifact_text, re.MULTILINE | re.IGNORECASE):
|
|
71
|
+
failures.append(f"forbidden section present: '{clean}'")
|
|
72
|
+
return failures
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_forbidden_tokens(sensor_md: str, artifact_text: str) -> list[str]:
|
|
76
|
+
body = section_body(sensor_md, "Forbidden tokens") or section_body(
|
|
77
|
+
sensor_md, "Forbidden patterns"
|
|
78
|
+
)
|
|
79
|
+
if not body:
|
|
80
|
+
return []
|
|
81
|
+
failures = []
|
|
82
|
+
for token in bullets(body):
|
|
83
|
+
clean = token.strip("`")
|
|
84
|
+
if re.search(rf"\b{re.escape(clean)}\b", artifact_text, re.IGNORECASE):
|
|
85
|
+
failures.append(f"forbidden token present: '{clean}'")
|
|
86
|
+
return failures
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_markdown_rules(sensor_md: str, artifact_text: str) -> list[str]:
|
|
90
|
+
body = section_body(sensor_md, "Markdown rules")
|
|
91
|
+
if not body:
|
|
92
|
+
return []
|
|
93
|
+
failures = []
|
|
94
|
+
if "exactly 1 H1" in body or "require_h1: true" in body or "1 H1 heading" in body:
|
|
95
|
+
h1_count = len(re.findall(r"^#\s+\S", artifact_text, re.MULTILINE))
|
|
96
|
+
if h1_count != 1:
|
|
97
|
+
failures.append(f"expected exactly 1 H1, found {h1_count}")
|
|
98
|
+
if "no em-dash" in body.lower() or "no em dash" in body.lower():
|
|
99
|
+
if "—" in artifact_text:
|
|
100
|
+
failures.append("em-dash (—) found; use commas/periods/parentheses instead")
|
|
101
|
+
if "no ascii" in body.lower():
|
|
102
|
+
if re.search(r"^\s*\+[-+]{2,}\+\s*$", artifact_text, re.MULTILINE):
|
|
103
|
+
failures.append("ASCII box-drawing detected; use Mermaid diagrams")
|
|
104
|
+
return failures
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> int:
|
|
108
|
+
parser = argparse.ArgumentParser()
|
|
109
|
+
parser.add_argument("--sensor", required=True, type=Path)
|
|
110
|
+
parser.add_argument("--artifact", required=True, type=Path)
|
|
111
|
+
args = parser.parse_args()
|
|
112
|
+
|
|
113
|
+
if not args.sensor.exists():
|
|
114
|
+
print(f"[sensor-runner] sensor file not found: {args.sensor}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
if not args.artifact.exists():
|
|
117
|
+
print(f"[sensor-runner] artifact file not found: {args.artifact}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
sensor_md = args.sensor.read_text(encoding="utf-8")
|
|
121
|
+
artifact_text = args.artifact.read_text(encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
failures: list[str] = []
|
|
124
|
+
failures += check_required_sections(sensor_md, artifact_text)
|
|
125
|
+
failures += check_forbidden_sections(sensor_md, artifact_text)
|
|
126
|
+
failures += check_forbidden_tokens(sensor_md, artifact_text)
|
|
127
|
+
failures += check_markdown_rules(sensor_md, artifact_text)
|
|
128
|
+
|
|
129
|
+
if failures:
|
|
130
|
+
print(f"[sensor-runner] {args.sensor.name} FAILED ({len(failures)} issue(s)):", file=sys.stderr)
|
|
131
|
+
for f in failures:
|
|
132
|
+
print(f" - {f}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
print(f"[sensor-runner] {args.sensor.name} passed", file=sys.stderr)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Token-phase accounting for the product-manager plugin.
|
|
4
|
+
|
|
5
|
+
Reads start and end markers, sums Claude session token usage from the
|
|
6
|
+
transcript JSONL within the time window, and appends a phase entry to
|
|
7
|
+
outputs/tokens/{feature_id}.json.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
token-phase.py --feature-id ID --phase NAME --plugin-dir PATH
|
|
11
|
+
[--prd-path PATH] [--prp-path PATH]
|
|
12
|
+
[--attempts N] [--details JSON]
|
|
13
|
+
|
|
14
|
+
Exit 0 on success or graceful skip. Non-zero only on programming bug.
|
|
15
|
+
Never blocks the calling hook.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_transcript(session_id: str | None) -> Path | None:
|
|
26
|
+
"""Locate the Claude Code transcript JSONL for the current session."""
|
|
27
|
+
cwd = Path.cwd().resolve()
|
|
28
|
+
encoded = str(cwd).replace("/", "-")
|
|
29
|
+
project_dir = Path.home() / ".claude" / "projects" / encoded
|
|
30
|
+
if not project_dir.exists():
|
|
31
|
+
return None
|
|
32
|
+
if session_id:
|
|
33
|
+
candidate = project_dir / f"{session_id}.jsonl"
|
|
34
|
+
if candidate.exists():
|
|
35
|
+
return candidate
|
|
36
|
+
jsonls = sorted(
|
|
37
|
+
project_dir.glob("*.jsonl"),
|
|
38
|
+
key=lambda p: p.stat().st_mtime,
|
|
39
|
+
reverse=True,
|
|
40
|
+
)
|
|
41
|
+
return jsonls[0] if jsonls else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_marker(path: Path) -> dict | None:
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f"[token-phase] failed to read marker {path.name}: {e}", file=sys.stderr)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sum_tokens(transcript: Path, started_at: str, ended_at: str) -> dict:
|
|
55
|
+
"""Sum usage tokens for assistant messages between start and end timestamps."""
|
|
56
|
+
totals = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}
|
|
57
|
+
try:
|
|
58
|
+
with open(transcript, "r", encoding="utf-8") as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
try:
|
|
61
|
+
entry = json.loads(line)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
continue
|
|
64
|
+
ts = entry.get("timestamp") or entry.get("ts")
|
|
65
|
+
if not ts:
|
|
66
|
+
continue
|
|
67
|
+
if ts < started_at or ts > ended_at:
|
|
68
|
+
continue
|
|
69
|
+
msg = entry.get("message") or {}
|
|
70
|
+
usage = msg.get("usage") or entry.get("usage")
|
|
71
|
+
if not usage:
|
|
72
|
+
continue
|
|
73
|
+
totals["input"] += usage.get("input_tokens", 0)
|
|
74
|
+
totals["output"] += usage.get("output_tokens", 0)
|
|
75
|
+
totals["cache_read"] += usage.get("cache_read_input_tokens", 0)
|
|
76
|
+
totals["cache_creation"] += usage.get(
|
|
77
|
+
"cache_creation_input_tokens", 0
|
|
78
|
+
)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f"[token-phase] failed to read transcript: {e}", file=sys.stderr)
|
|
81
|
+
return totals
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def update_tokens_json(
|
|
85
|
+
tokens_path: Path, feature_id: str, files: dict, phase_entry: dict
|
|
86
|
+
):
|
|
87
|
+
if tokens_path.exists():
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(tokens_path.read_text(encoding="utf-8"))
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
data = None
|
|
92
|
+
else:
|
|
93
|
+
data = None
|
|
94
|
+
|
|
95
|
+
if data is None:
|
|
96
|
+
data = {
|
|
97
|
+
"feature_id": feature_id,
|
|
98
|
+
"files": {},
|
|
99
|
+
"phases": [],
|
|
100
|
+
"totals": {
|
|
101
|
+
"input": 0,
|
|
102
|
+
"output": 0,
|
|
103
|
+
"cache_read": 0,
|
|
104
|
+
"cache_creation": 0,
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
data["files"].update({k: v for k, v in files.items() if v})
|
|
109
|
+
|
|
110
|
+
duplicate = [
|
|
111
|
+
p
|
|
112
|
+
for p in data["phases"]
|
|
113
|
+
if p.get("phase") == phase_entry["phase"]
|
|
114
|
+
and p.get("started_at") == phase_entry["started_at"]
|
|
115
|
+
]
|
|
116
|
+
if not duplicate:
|
|
117
|
+
data["phases"].append(phase_entry)
|
|
118
|
+
|
|
119
|
+
totals = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}
|
|
120
|
+
for p in data["phases"]:
|
|
121
|
+
for k in totals:
|
|
122
|
+
totals[k] += p.get("tokens", {}).get(k, 0)
|
|
123
|
+
data["totals"] = totals
|
|
124
|
+
|
|
125
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
tokens_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
parser = argparse.ArgumentParser()
|
|
131
|
+
parser.add_argument("--feature-id", required=True)
|
|
132
|
+
parser.add_argument("--phase", required=True)
|
|
133
|
+
parser.add_argument("--plugin-dir", required=True, type=Path)
|
|
134
|
+
parser.add_argument("--prd-path", default="")
|
|
135
|
+
parser.add_argument("--prp-path", default="")
|
|
136
|
+
parser.add_argument("--attempts", type=int, default=1)
|
|
137
|
+
parser.add_argument("--details", default="{}")
|
|
138
|
+
args = parser.parse_args()
|
|
139
|
+
|
|
140
|
+
markers_dir = args.plugin_dir / "outputs" / ".markers"
|
|
141
|
+
start_marker = markers_dir / f"{args.feature_id}.{args.phase}.start"
|
|
142
|
+
end_marker = markers_dir / f"{args.feature_id}.{args.phase}.end"
|
|
143
|
+
|
|
144
|
+
start_data = read_marker(start_marker)
|
|
145
|
+
end_data = read_marker(end_marker)
|
|
146
|
+
if not start_data or not end_data:
|
|
147
|
+
print(
|
|
148
|
+
f"[token-phase] missing markers for phase {args.phase} (feature {args.feature_id})",
|
|
149
|
+
file=sys.stderr,
|
|
150
|
+
)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
started_at = start_data.get("timestamp")
|
|
154
|
+
ended_at = end_data.get("timestamp")
|
|
155
|
+
if not started_at or not ended_at:
|
|
156
|
+
print("[token-phase] marker missing timestamp field", file=sys.stderr)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
session_id = start_data.get("session_id") or os.environ.get("CLAUDE_SESSION_ID")
|
|
160
|
+
|
|
161
|
+
transcript = find_transcript(session_id)
|
|
162
|
+
if not transcript:
|
|
163
|
+
print("[token-phase] transcript not found, skipping", file=sys.stderr)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
tokens = sum_tokens(transcript, started_at, ended_at)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
details = json.loads(args.details) if args.details else {}
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
details = {}
|
|
172
|
+
|
|
173
|
+
phase_entry = {
|
|
174
|
+
"phase": args.phase,
|
|
175
|
+
"started_at": started_at,
|
|
176
|
+
"ended_at": ended_at,
|
|
177
|
+
"tokens": tokens,
|
|
178
|
+
"attempts": args.attempts,
|
|
179
|
+
}
|
|
180
|
+
if details:
|
|
181
|
+
phase_entry["details"] = details
|
|
182
|
+
|
|
183
|
+
files = {}
|
|
184
|
+
if args.prd_path:
|
|
185
|
+
files["prd"] = args.prd_path
|
|
186
|
+
if args.prp_path:
|
|
187
|
+
files["prp"] = args.prp_path
|
|
188
|
+
|
|
189
|
+
tokens_path = (
|
|
190
|
+
args.plugin_dir / "outputs" / "tokens" / f"{args.feature_id}.json"
|
|
191
|
+
)
|
|
192
|
+
update_tokens_json(tokens_path, args.feature_id, files, phase_entry)
|
|
193
|
+
|
|
194
|
+
for m in (start_marker, end_marker):
|
|
195
|
+
try:
|
|
196
|
+
m.unlink()
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
print(
|
|
201
|
+
f"[token-phase] {args.phase}: in={tokens['input']} out={tokens['output']} cache_r={tokens['cache_read']}",
|
|
202
|
+
file=sys.stderr,
|
|
203
|
+
)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Sensor: PRD Acceptance Criteria
|
|
2
|
+
|
|
3
|
+
Type: deterministic
|
|
4
|
+
Mode: hard gate
|
|
5
|
+
|
|
6
|
+
## Required sections
|
|
7
|
+
|
|
8
|
+
- Solution Overview
|
|
9
|
+
- Success Metrics
|
|
10
|
+
- Rollout
|
|
11
|
+
|
|
12
|
+
## Markdown rules
|
|
13
|
+
|
|
14
|
+
- exactly 1 H1 heading
|
|
15
|
+
- no em-dash
|
|
16
|
+
|
|
17
|
+
## Checks
|
|
18
|
+
|
|
19
|
+
User story format. Every bullet in Solution Overview that starts with "As a" must follow:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
- As a {persona}, I want {action}, so that {benefit}.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Metric completeness. Success Metrics table: every row must have non-empty Baseline, Target, Horizon. Cells with only "TBD", "N/A", "?", or empty fail.
|
|
26
|
+
|
|
27
|
+
Guardrails block. A "Guardrails" line must exist in Success Metrics.
|
|
28
|
+
|
|
29
|
+
Kill criteria. A "Kill criteria:" line must exist in Success Metrics and contain at least one digit.
|
|
30
|
+
|
|
31
|
+
Rollout phases. Rollout table needs at least 2 rows.
|
|
32
|
+
|
|
33
|
+
Non-goals listed. Scope and Non-Goals must have a Non-goals subsection with 1-3 bullets.
|
|
34
|
+
|
|
35
|
+
Open gaps budget. Count of `NOT FOUND` markers must be 5 or fewer.
|
|
36
|
+
|
|
37
|
+
## On failure
|
|
38
|
+
|
|
39
|
+
Block publish. Surface each violation with line number. Agent regenerates affected sections only.
|