@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.
Files changed (77) hide show
  1. package/.claude/agents/product-manager.md +20 -0
  2. package/.claude/agents/staff-software-engineer.md +25 -0
  3. package/.claude/commands/product-manager/prd.md +31 -0
  4. package/.claude/commands/product-manager/prp.md +35 -0
  5. package/.claude/commands/product-manager/run.md +31 -0
  6. package/.claude/commands/sse/dev.md +47 -0
  7. package/.claude/commands/sse/plan.md +33 -0
  8. package/.claude/commands/sse/pr.md +43 -0
  9. package/.claude/commands/sse/run.md +39 -0
  10. package/.claude/commands/sse/test.md +38 -0
  11. package/.claude/hooks/status-line.sh +103 -0
  12. package/.claude/plugins/product-manager/README.md +120 -0
  13. package/.claude/plugins/product-manager/evals/prd-quality.md +88 -0
  14. package/.claude/plugins/product-manager/evals/prd-readiness.md +66 -0
  15. package/.claude/plugins/product-manager/evals/prp-context-readiness.md +51 -0
  16. package/.claude/plugins/product-manager/evals/prp-quality.md +88 -0
  17. package/.claude/plugins/product-manager/guides/examples/good-prd-example.md +121 -0
  18. package/.claude/plugins/product-manager/guides/examples/good-prp-example.md +128 -0
  19. package/.claude/plugins/product-manager/guides/pipeline.md +84 -0
  20. package/.claude/plugins/product-manager/guides/prd-guidelines.md +27 -0
  21. package/.claude/plugins/product-manager/guides/product-guidelines.md +75 -0
  22. package/.claude/plugins/product-manager/guides/prp-guidelines.md +64 -0
  23. package/.claude/plugins/product-manager/guides/templates/prd.md +89 -0
  24. package/.claude/plugins/product-manager/guides/templates/prp.md +98 -0
  25. package/.claude/plugins/product-manager/guides/writing-style.md +71 -0
  26. package/.claude/plugins/product-manager/hooks/post-eval-prd.sh +77 -0
  27. package/.claude/plugins/product-manager/hooks/post-eval-prp.sh +70 -0
  28. package/.claude/plugins/product-manager/hooks/post-write-prd.sh +56 -0
  29. package/.claude/plugins/product-manager/hooks/post-write-prp.sh +61 -0
  30. package/.claude/plugins/product-manager/hooks/pre-prp-check.sh +48 -0
  31. package/.claude/plugins/product-manager/outputs/.markers/.gitkeep +0 -0
  32. package/.claude/plugins/product-manager/scripts/confluence-publish.py +205 -0
  33. package/.claude/plugins/product-manager/scripts/link-validator.py +87 -0
  34. package/.claude/plugins/product-manager/scripts/sensor-runner.py +140 -0
  35. package/.claude/plugins/product-manager/scripts/token-phase.py +208 -0
  36. package/.claude/plugins/product-manager/sensors/prd-acceptance-criteria.md +39 -0
  37. package/.claude/plugins/product-manager/sensors/prd-structure.md +39 -0
  38. package/.claude/plugins/product-manager/sensors/prp-context-quality.md +42 -0
  39. package/.claude/plugins/product-manager/sensors/prp-links.md +24 -0
  40. package/.claude/plugins/product-manager/sensors/prp-structure.md +52 -0
  41. package/.claude/plugins/product-manager/skills/prd/SKILL.md +33 -0
  42. package/.claude/plugins/product-manager/skills/prp/SKILL.md +37 -0
  43. package/.claude/plugins/staff-software-engineer/README.md +90 -0
  44. package/.claude/plugins/staff-software-engineer/evals/plan-quality.md +48 -0
  45. package/.claude/plugins/staff-software-engineer/guides/coding-style.md +51 -0
  46. package/.claude/plugins/staff-software-engineer/guides/commit-style.md +44 -0
  47. package/.claude/plugins/staff-software-engineer/guides/conventions-override.md +79 -0
  48. package/.claude/plugins/staff-software-engineer/guides/pipeline.md +69 -0
  49. package/.claude/plugins/staff-software-engineer/hooks/post-eval-plan.sh +43 -0
  50. package/.claude/plugins/staff-software-engineer/hooks/post-write-plan.sh +49 -0
  51. package/.claude/plugins/staff-software-engineer/outputs/.markers/.gitkeep +0 -0
  52. package/.claude/plugins/staff-software-engineer/sensors/code-conventions.md +37 -0
  53. package/.claude/plugins/staff-software-engineer/sensors/plan-structure.md +37 -0
  54. package/.claude/plugins/staff-software-engineer/sensors/test-coverage.md +28 -0
  55. package/.claude/plugins/staff-software-engineer/skills/backend/SKILL.md +80 -0
  56. package/.claude/plugins/staff-software-engineer/skills/devops/SKILL.md +58 -0
  57. package/.claude/plugins/staff-software-engineer/skills/mobile/SKILL.md +52 -0
  58. package/.claude/plugins/staff-software-engineer/skills/web/SKILL.md +64 -0
  59. package/.claude/settings.local.json +61 -0
  60. package/CLAUDE.md +90 -0
  61. package/LICENSE +21 -0
  62. package/README.md +192 -0
  63. package/VERSION +1 -0
  64. package/bin/hk.js +141 -0
  65. package/context-library/README.md +38 -0
  66. package/context-library/business-info-template.md +39 -0
  67. package/context-library/decisions/README.md +3 -0
  68. package/context-library/example-prds/README.md +3 -0
  69. package/context-library/meetings/.gitkeep +0 -0
  70. package/context-library/metrics/.gitkeep +0 -0
  71. package/context-library/personal-context-template.md +31 -0
  72. package/context-library/research/.gitkeep +0 -0
  73. package/context-library/squads/README.md +32 -0
  74. package/context-library/strategy/README.md +3 -0
  75. package/package.json +43 -0
  76. package/setup/install.sh +154 -0
  77. 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.