@surlymochan/evalid 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +315 -2
  4. package/RELEASE_NOTES.md +102 -0
  5. package/atoms/registry.json +758 -0
  6. package/bin/xflow.js +7208 -0
  7. package/docs/adoption/README.md +128 -0
  8. package/docs/adoption/openflow-release-hardening.md +61 -0
  9. package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
  10. package/docs/compatibility.md +52 -0
  11. package/docs/competitive-benchmark.md +108 -0
  12. package/docs/corps-operator-guide.md +75 -0
  13. package/docs/demo-proof.md +126 -0
  14. package/docs/examples-gallery.md +115 -0
  15. package/docs/fixtures/tracker-item.json +7 -0
  16. package/docs/goal-vs-codex.md +129 -0
  17. package/docs/gstack-comparison.md +88 -0
  18. package/docs/install-upgrade.md +133 -0
  19. package/docs/integrations.md +49 -0
  20. package/docs/launch-demo.md +123 -0
  21. package/docs/launch-dossier.md +180 -0
  22. package/docs/methodology.md +149 -0
  23. package/docs/npm-publish-handoff.md +83 -0
  24. package/docs/openspec-migration.md +106 -0
  25. package/docs/public-benchmark.md +68 -0
  26. package/docs/public-release.md +145 -0
  27. package/docs/quality-assessment.md +139 -0
  28. package/docs/quickstart.md +156 -0
  29. package/docs/reviewer-guide.md +43 -0
  30. package/docs/spec-kit-benchmark.md +73 -0
  31. package/docs/super-assistant-comparison.md +106 -0
  32. package/docs/superpowers-comparison.md +82 -0
  33. package/docs/team-adoption.md +123 -0
  34. package/docs/tooling-matrix.md +91 -0
  35. package/docs/walkthrough.md +76 -0
  36. package/docs/workflow-manual.md +1125 -0
  37. package/package.json +83 -4
  38. package/prompts/pencil-draft.md +46 -0
  39. package/prompts/pencil-refine.md +46 -0
  40. package/prompts/phase-artifact.md +24 -0
  41. package/prompts/phase-execution.md +32 -0
  42. package/schemas/competitor-reconstruction-contract.schema.json +91 -0
  43. package/schemas/llm-design-review-aggregate.schema.json +83 -0
  44. package/schemas/visual-benchmark.schema.json +978 -0
  45. package/schemas/workflow.schema.json +100 -0
  46. package/scripts/run-tests.mjs +23 -0
  47. package/scripts/takein_digest.py +139 -0
  48. package/specs/README.md +118 -0
  49. package/specs/architecture.md +36 -0
  50. package/specs/product.md +33 -0
  51. package/specs/workflow.md +349 -0
  52. package/src/agent_team/atoms/codex-app-bridge.js +8 -0
  53. package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
  54. package/src/agent_team/atoms/design/contract-freeze.js +8 -0
  55. package/src/agent_team/atoms/design/generation-contract.js +442 -0
  56. package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
  57. package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
  58. package/src/agent_team/atoms/patch-challenge.js +19 -0
  59. package/src/agent_team/atoms/pencil/accept.js +9 -0
  60. package/src/agent_team/atoms/pencil/draft.js +365 -0
  61. package/src/agent_team/atoms/pencil/refine.js +229 -0
  62. package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
  63. package/src/agent_team/atoms/team-run.js +380 -0
  64. package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
  65. package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
  66. package/src/agent_team/atoms/visual/benchmark.js +1652 -0
  67. package/src/agent_team/atoms/visual/design-selection.js +23 -0
  68. package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
  69. package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
  70. package/src/agent_team/atoms/visual/image-diff.js +186 -0
  71. package/src/agent_team/atoms/visual/layout-competition.js +8 -0
  72. package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
  73. package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
  74. package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
  75. package/src/agent_team/atoms/visual/review.js +77 -0
  76. package/src/agent_team/atoms/visual/visual-report.js +197 -0
  77. package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
  78. package/src/agent_team/common.js +311 -0
  79. package/src/core/atoms/agent-invoke.js +684 -0
  80. package/src/core/atoms/event/ingest.js +30 -0
  81. package/src/core/atoms/experience/digest.js +55 -0
  82. package/src/core/atoms/gate/artifact-verify.js +83 -0
  83. package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
  84. package/src/core/atoms/gate/evidence-collect.js +64 -0
  85. package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
  86. package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
  87. package/src/core/atoms/gate/workbench.js +79 -0
  88. package/src/core/atoms/intake/classify.js +15 -0
  89. package/src/core/atoms/intake/design-skill-select.js +16 -0
  90. package/src/core/atoms/intake/preview-or-refine.js +19 -0
  91. package/src/core/atoms/intake/recommend.js +20 -0
  92. package/src/core/atoms/intake/research-same-category.js +19 -0
  93. package/src/core/atoms/intervention/create.js +30 -0
  94. package/src/core/atoms/memory/manage.js +27 -0
  95. package/src/core/atoms/memory/mount.js +13 -0
  96. package/src/core/atoms/mission/create.js +19 -0
  97. package/src/core/atoms/mission/list.js +14 -0
  98. package/src/core/atoms/mission/show.js +26 -0
  99. package/src/core/atoms/notify/checkpoint.js +46 -0
  100. package/src/core/atoms/phase-run/complete.js +42 -0
  101. package/src/core/atoms/phase-run/fail.js +40 -0
  102. package/src/core/atoms/phase-run/persist.js +39 -0
  103. package/src/core/atoms/phase-run/start.js +30 -0
  104. package/src/core/atoms/plugin/run.js +76 -0
  105. package/src/core/atoms/policy/gate.js +90 -0
  106. package/src/core/atoms/policy/resolve.js +11 -0
  107. package/src/core/atoms/storage/sync.js +57 -0
  108. package/src/core/atoms/workflow/advance.js +25 -0
  109. package/src/core/atoms/workflow/guard.js +127 -0
  110. package/src/core/atoms/workflow/state-load.js +37 -0
  111. package/src/core/bindings.js +37 -0
  112. package/src/core/change-artifacts.js +134 -0
  113. package/src/core/competitive-score.js +265 -0
  114. package/src/core/concurrency-policy.js +35 -0
  115. package/src/core/execution-log.js +108 -0
  116. package/src/core/intake-recommendation.js +134 -0
  117. package/src/core/memory-state.js +62 -0
  118. package/src/core/mission-state.js +780 -0
  119. package/src/core/pending-gates.js +66 -0
  120. package/src/core/phase-cost.js +116 -0
  121. package/src/core/phase-run-read.js +27 -0
  122. package/src/core/policy-overlay.js +180 -0
  123. package/src/core/state-store.js +201 -0
  124. package/src/core/workflow-executor.js +998 -0
  125. package/src/core/workflow-integrity.js +111 -0
  126. package/src/core/workflow-loader.js +163 -0
  127. package/src/core/workflow-rerun.js +84 -0
  128. package/src/core/workflow-state.js +119 -0
  129. package/src/server.js +1307 -0
  130. package/workflows/corps.yaml +479 -0
  131. package/workflows/yolo.yaml +233 -0
  132. package/xflow/README.md +76 -0
  133. package/xflow/aha/SKILL.md +82 -0
  134. package/xflow/aha/scripts/append_aha.py +50 -0
  135. package/xflow/archive/scripts/archive_change.py +338 -0
  136. package/xflow/archive/templates/merge-design.md +54 -0
  137. package/xflow/archive/templates/merge-workflow.md +26 -0
  138. package/xflow/atoms/__init__.py +6 -0
  139. package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
  140. package/xflow/atoms/a2_branch_create_and_link.py +42 -0
  141. package/xflow/atoms/a3_repo_doctor.py +11 -0
  142. package/xflow/atoms/a4_project_set_status.py +11 -0
  143. package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
  144. package/xflow/atoms/a6_pr_create.py +72 -0
  145. package/xflow/atoms/b1_change_scaffold.py +11 -0
  146. package/xflow/atoms/b2_status_read.py +16 -0
  147. package/xflow/atoms/b2b_status_write.py +20 -0
  148. package/xflow/atoms/b3_status_transition.py +27 -0
  149. package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
  150. package/xflow/atoms/b5_aha_append.py +11 -0
  151. package/xflow/atoms/b6_takein_digest.py +11 -0
  152. package/xflow/atoms/b6b_lookback_digest.py +11 -0
  153. package/xflow/atoms/b7_change_mission_bind.py +43 -0
  154. package/xflow/atoms/c1_mem.py +54 -0
  155. package/xflow/atoms/e6_gate_local_precheck.py +23 -0
  156. package/xflow/atoms/h1_design_lite_gate.py +74 -0
  157. package/xflow/atoms/i6a_tdd_run.py +104 -0
  158. package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
  159. package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
  160. package/xflow/atoms/j1_tests_run.py +129 -0
  161. package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
  162. package/xflow/atoms/j4b_spec_delta_review.py +316 -0
  163. package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
  164. package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
  165. package/xflow/atoms/j6_cr_review.py +443 -0
  166. package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
  167. package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
  168. package/xflow/atoms/k3_handoff_refresh.py +14 -0
  169. package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
  170. package/xflow/atoms/k5_archive_publish.py +21 -0
  171. package/xflow/atoms/k6_aha_merge.py +42 -0
  172. package/xflow/atoms/tdd_lib.py +747 -0
  173. package/xflow/corps/SKILL.md +109 -0
  174. package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
  175. package/xflow/goal/SKILL.md +150 -0
  176. package/xflow/handoff/SKILL.md +95 -0
  177. package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
  178. package/xflow/lookback/SKILL.md +66 -0
  179. package/xflow/lookback/scripts/lookback_digest.py +46 -0
  180. package/xflow/openissue/scripts/open_issue_flow.py +438 -0
  181. package/xflow/plan/SKILL.md +38 -0
  182. package/xflow/ralph/SKILL.md +123 -0
  183. package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
  184. package/xflow/scripts/common.py +480 -0
  185. package/xflow/scripts/doctor.py +278 -0
  186. package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
  187. package/xflow/takein/SKILL.md +81 -0
  188. package/xflow/takein/scripts/takein_digest.py +88 -0
  189. package/xflow/yolo/SKILL.md +66 -0
  190. package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
  191. package/xflow/yolo/scripts/yolo_gate.py +312 -0
  192. package/xflow/yolo/templates/DESIGN.md +179 -0
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ J6.cr.review - Deterministic first-pass code review gate.
4
+
5
+ Runs before PR creation / archive. This is intentionally not an LLM judge:
6
+ an LLM reviewing its own diff tends to wave through exactly the failures
7
+ this gate checks with plain code.
8
+
9
+ Checks (findings are structured JSON, severity fail | warn):
10
+ 1. Spec alignment - every changed code file must be traceable to the
11
+ change's spec text (status.json title, proposal.md, plan.md, tasks.md,
12
+ merge-*.md delta snippets): the file stem or a symbol it defines must
13
+ be named there, or share a meaningful token with it. Untraceable files
14
+ are warns; if spec text exists and NO changed code file traces back to
15
+ it, the gate fails.
16
+ 2. Test follow - every changed code file must either have a changed test
17
+ that references one of its symbols (or matches its stem), or an
18
+ existing repo test that references one of its symbols. Paths listed in
19
+ status.json "cr_allow_untested" are exempt and reported as exempt.
20
+ 3. Assertion balance - across changed test files, assertions removed
21
+ must not outnumber assertions added. A net decrease fails: shrinking
22
+ the tests to make them pass is not a green build.
23
+ 4. Loop budget - the diff for one change stays small on purpose (few,
24
+ high-quality loops over one giant loop). Changed file count and
25
+ changed line count are compared against configurable thresholds
26
+ (--max-files / --max-lines, or status.json cr_max_files /
27
+ cr_max_lines). Over budget is a warn with a split-the-spec
28
+ recommendation; over 2x budget fails.
29
+
30
+ Self-contained on purpose: this atom does not import tdd_lib so it can
31
+ run standalone in a reviewed tree. The file/symbol/assertion semantics
32
+ mirror tdd_lib so verdicts agree with I6c.
33
+
34
+ The proof is written to specs/changes/<change_id>/cr/review-0.json; that
35
+ directory is excluded from the reviewed diff.
36
+ """
37
+ import argparse
38
+ import fnmatch
39
+ import json
40
+ import os
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+
47
+
48
+ CODE_EXTENSIONS = {
49
+ ".js", ".jsx", ".mjs", ".cjs",
50
+ ".ts", ".tsx",
51
+ ".py", ".go", ".rs", ".java", ".kt", ".rb", ".php",
52
+ }
53
+ TEST_PATTERNS = [
54
+ re.compile(r"(^|/)(test|tests|spec|__tests__)/"),
55
+ re.compile(r"(^|/)[^/]+(\.test|\.spec|_test)\.[^/]+$"),
56
+ re.compile(r"(^|/)test_[^/]+\.py$"),
57
+ ]
58
+ SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".pytest_cache"}
59
+ ASSERTION_PATTERN = re.compile(
60
+ r"(?:"
61
+ r"\bassert\.(?:ok|equal|strictEqual|deepEqual|deepStrictEqual|notEqual|notStrictEqual|match|doesNotMatch|throws|rejects)\b"
62
+ r"|\bassert\s+(?!from\b)\S"
63
+ r"|\bself\.assert[A-Z]\w*"
64
+ r"|\bpytest\.raises\b"
65
+ r"|\bexpect\s*\("
66
+ r"|\bshould\."
67
+ r"|\.should\b"
68
+ r")"
69
+ )
70
+ GENERIC_TOKENS = {
71
+ "src", "lib", "bin", "core", "app", "index", "main", "util", "utils",
72
+ "common", "helper", "helpers", "test", "tests", "spec", "the", "and",
73
+ "for", "with", "change", "changes", "update", "updates", "add", "new",
74
+ "file", "files", "code", "this", "that", "from", "into", "when", "then",
75
+ }
76
+ DEFAULT_MAX_FILES = 10
77
+ DEFAULT_MAX_LINES = 400
78
+ SPEC_TEXT_FILES = ["proposal.md", "plan.md", "tasks.md", "design.md"]
79
+
80
+
81
+ def is_test_file(path: str) -> bool:
82
+ if path.startswith(("docs/", "specs/", "test/fixtures/", "tests/fixtures/")):
83
+ return False
84
+ if Path(path).suffix not in CODE_EXTENSIONS:
85
+ return False
86
+ return any(pattern.search(path) for pattern in TEST_PATTERNS)
87
+
88
+
89
+ def is_code_file(path: str) -> bool:
90
+ if Path(path).suffix not in CODE_EXTENSIONS:
91
+ return False
92
+ if path.startswith(("docs/", "specs/changes/")):
93
+ return False
94
+ return not is_test_file(path)
95
+
96
+
97
+ def defined_symbols(content: str) -> set:
98
+ names = set()
99
+ names.update(re.findall(r"\b(?:async\s+)?function\s+([A-Za-z_]\w*)", content))
100
+ names.update(re.findall(r"\bclass\s+([A-Za-z_]\w*)", content))
101
+ names.update(re.findall(r"\bdef\s+([A-Za-z_]\w*)", content))
102
+ names.update(re.findall(r"\bexport\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_]\w*)", content))
103
+ names.update(re.findall(r"\bconst\s+([A-Za-z_]\w*)\s*=\s*(?:async\s*)?\(", content))
104
+ return names
105
+
106
+
107
+ def references_symbol(content: str, symbols: set) -> bool:
108
+ return any(re.search(rf"\b{re.escape(name)}\b", content) for name in symbols)
109
+
110
+
111
+ def assertion_count(content: str) -> int:
112
+ return len(ASSERTION_PATTERN.findall(content))
113
+
114
+
115
+ def read_text(path: Path) -> str:
116
+ try:
117
+ return path.read_text(encoding="utf-8", errors="replace")
118
+ except OSError:
119
+ return ""
120
+
121
+
122
+ def read_json(path: Path) -> dict:
123
+ try:
124
+ return json.loads(path.read_text(encoding="utf-8"))
125
+ except (OSError, json.JSONDecodeError):
126
+ return {}
127
+
128
+
129
+ def run_git(project_root: Path, args: list) -> subprocess.CompletedProcess:
130
+ return subprocess.run(
131
+ ["git", *args], cwd=project_root, text=True, capture_output=True,
132
+ )
133
+
134
+
135
+ def changed_files(project_root: Path, diff_ref: str) -> list:
136
+ result = run_git(project_root, ["diff", "--name-only", "--diff-filter=ACMR", diff_ref])
137
+ tracked = [] if result.returncode != 0 else [
138
+ line.strip() for line in result.stdout.splitlines() if line.strip()
139
+ ]
140
+ untracked_result = run_git(project_root, ["ls-files", "--others", "--exclude-standard"])
141
+ untracked = [] if untracked_result.returncode != 0 else [
142
+ line.strip() for line in untracked_result.stdout.splitlines() if line.strip()
143
+ ]
144
+ return sorted(set(tracked + untracked))
145
+
146
+
147
+ def deleted_files(project_root: Path, diff_ref: str) -> list:
148
+ result = run_git(project_root, ["diff", "--name-only", "--diff-filter=D", diff_ref])
149
+ if result.returncode != 0:
150
+ return []
151
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
152
+
153
+
154
+ def line_counts(project_root: Path, diff_ref: str, files: list) -> dict:
155
+ counts = {}
156
+ result = run_git(project_root, ["diff", "--numstat", diff_ref])
157
+ if result.returncode == 0:
158
+ for line in result.stdout.splitlines():
159
+ parts = line.split("\t")
160
+ if len(parts) != 3:
161
+ continue
162
+ added, removed, path = parts
163
+ counts[path] = (
164
+ int(added) if added.isdigit() else 0,
165
+ int(removed) if removed.isdigit() else 0,
166
+ )
167
+ for relative in files:
168
+ if relative not in counts:
169
+ # Untracked file: every line counts as added.
170
+ content = read_text(project_root / relative)
171
+ counts[relative] = (len(content.splitlines()), 0) if content else (0, 0)
172
+ return counts
173
+
174
+
175
+ def assertion_delta(project_root: Path, diff_ref: str, test_files: list, deleted: list) -> tuple:
176
+ added = 0
177
+ removed = 0
178
+ tracked = set()
179
+ result = run_git(project_root, ["diff", "--name-only", "--diff-filter=ACMR", diff_ref])
180
+ if result.returncode == 0:
181
+ tracked = {line.strip() for line in result.stdout.splitlines() if line.strip()}
182
+ for relative in test_files:
183
+ if relative in tracked:
184
+ diff = run_git(project_root, ["diff", "--unified=0", diff_ref, "--", relative])
185
+ if diff.returncode == 0:
186
+ for line in diff.stdout.splitlines():
187
+ if line.startswith(("+++", "---")):
188
+ continue
189
+ if line.startswith("+"):
190
+ added += assertion_count(line[1:])
191
+ elif line.startswith("-"):
192
+ removed += assertion_count(line[1:])
193
+ else:
194
+ added += assertion_count(read_text(project_root / relative))
195
+ for relative in deleted:
196
+ if is_test_file(relative):
197
+ baseline = run_git(project_root, ["show", f"{diff_ref}:{relative}"])
198
+ if baseline.returncode == 0:
199
+ removed += assertion_count(baseline.stdout)
200
+ return added, removed
201
+
202
+
203
+ def tokenize(text: str) -> set:
204
+ tokens = set()
205
+ for raw in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text):
206
+ tokens.add(raw.lower())
207
+ for part in re.findall(r"[a-z]+|[A-Z]+(?![a-z])|[A-Z][a-z]+|\d+", raw):
208
+ tokens.add(part.lower())
209
+ for part in raw.split("_"):
210
+ if part:
211
+ tokens.add(part.lower())
212
+ return {token for token in tokens if len(token) >= 3 and token not in GENERIC_TOKENS}
213
+
214
+
215
+ def file_tokens(relative: str, content: str) -> set:
216
+ tokens = set()
217
+ stem = Path(relative).stem
218
+ tokens |= tokenize(stem)
219
+ tokens |= tokenize(relative.replace("/", " ").replace("-", " "))
220
+ for symbol in defined_symbols(content):
221
+ tokens |= tokenize(symbol)
222
+ return tokens
223
+
224
+
225
+ def collect_spec_text(project_root: Path, change_dir: Path, status: dict) -> tuple:
226
+ chunks = []
227
+ if status.get("title"):
228
+ chunks.append(str(status["title"]))
229
+ for name in SPEC_TEXT_FILES:
230
+ chunks.append(read_text(change_dir / name))
231
+ if change_dir.is_dir():
232
+ for merge_file in sorted(change_dir.glob("merge-*.md")):
233
+ chunks.append(read_text(merge_file))
234
+ text = "\n".join(chunk for chunk in chunks if chunk)
235
+ return text, bool(text.strip())
236
+
237
+
238
+ def iter_repo_tests(project_root: Path) -> list:
239
+ found = []
240
+ for dirpath, dirnames, filenames in os.walk(project_root):
241
+ dirnames[:] = [name for name in dirnames if name not in SKIP_DIR_NAMES]
242
+ for name in filenames:
243
+ full = Path(dirpath) / name
244
+ rel = full.relative_to(project_root).as_posix()
245
+ if is_test_file(rel):
246
+ found.append(rel)
247
+ return sorted(found)
248
+
249
+
250
+ def main() -> None:
251
+ parser = argparse.ArgumentParser()
252
+ parser.add_argument("--project-root", default=".")
253
+ parser.add_argument("--change-id", required=True)
254
+ parser.add_argument("--diff-ref", default="HEAD")
255
+ parser.add_argument("--max-files", type=int, default=None,
256
+ help="Loop budget: max changed files for one change")
257
+ parser.add_argument("--max-lines", type=int, default=None,
258
+ help="Loop budget: max changed lines (add+del) for one change")
259
+ args = parser.parse_args()
260
+
261
+ project_root = Path(args.project_root).resolve()
262
+ change_dir = project_root / "specs" / "changes" / args.change_id
263
+ status = read_json(change_dir / "status.json")
264
+
265
+ findings = []
266
+
267
+ def emit_and_exit(payload: dict, code: int) -> None:
268
+ print(json.dumps(payload))
269
+ sys.exit(code)
270
+
271
+ probe = run_git(project_root, ["rev-parse", "--git-dir"])
272
+ if probe.returncode != 0:
273
+ emit_and_exit({
274
+ "ok": False, "verdict": "failed", "change_id": args.change_id,
275
+ "failures": 1, "warnings": 0, "failed_codes": ["cr_not_a_git_repo"],
276
+ "findings": [{
277
+ "severity": "fail", "code": "cr_not_a_git_repo",
278
+ "message": "CR review needs a git working tree to diff against.",
279
+ }],
280
+ }, 1)
281
+
282
+ evidence_prefix = f"specs/changes/{args.change_id}/"
283
+ all_files = [
284
+ path for path in changed_files(project_root, args.diff_ref)
285
+ if not path.startswith(evidence_prefix)
286
+ ]
287
+ deleted = [
288
+ path for path in deleted_files(project_root, args.diff_ref)
289
+ if not path.startswith(evidence_prefix)
290
+ ]
291
+ code_files = [path for path in all_files if is_code_file(path)]
292
+ test_files = [path for path in all_files if is_test_file(path)]
293
+ counts = line_counts(project_root, args.diff_ref, all_files)
294
+
295
+ # ---- Check 1: spec alignment -------------------------------------
296
+ spec_text, spec_present = collect_spec_text(project_root, change_dir, status)
297
+ spec_tokens = tokenize(spec_text)
298
+ spec_lower = spec_text.lower()
299
+ aligned_files = []
300
+ if code_files and not spec_present:
301
+ findings.append({
302
+ "severity": "warn", "code": "cr_spec_missing",
303
+ "message": "No spec text found for this change (status title, proposal, plan, tasks, or merge-*.md); spec alignment cannot be verified.",
304
+ })
305
+ elif code_files:
306
+ for relative in code_files:
307
+ content = read_text(project_root / relative)
308
+ stem = Path(relative).stem.lower()
309
+ symbols = defined_symbols(content)
310
+ named = stem in spec_lower or any(
311
+ symbol.lower() in spec_lower for symbol in symbols
312
+ )
313
+ overlap = file_tokens(relative, content) & spec_tokens
314
+ if named or overlap:
315
+ aligned_files.append(relative)
316
+ else:
317
+ findings.append({
318
+ "severity": "warn", "code": "cr_spec_file_unaligned",
319
+ "path": relative,
320
+ "message": "Changed code file is not named or token-traceable to the change spec text; clarify the spec or split this file into its own change.",
321
+ })
322
+ if not aligned_files:
323
+ findings.append({
324
+ "severity": "fail", "code": "cr_spec_alignment_none",
325
+ "message": "Spec text exists but no changed code file traces back to it; the diff and the spec describe different work.",
326
+ "code_files": code_files,
327
+ })
328
+
329
+ # ---- Check 2: test follow ----------------------------------------
330
+ allow_untested = status.get("cr_allow_untested") or []
331
+ repo_tests = None
332
+ for relative in code_files:
333
+ if any(fnmatch.fnmatch(relative, pattern) for pattern in allow_untested):
334
+ findings.append({
335
+ "severity": "warn", "code": "cr_test_exempted",
336
+ "path": relative,
337
+ "message": "Code file is exempt from test follow via status.json cr_allow_untested.",
338
+ })
339
+ continue
340
+ content = read_text(project_root / relative)
341
+ symbols = defined_symbols(content)
342
+ stem = Path(relative).stem.lower()
343
+ covered_by = None
344
+ for test_rel in test_files:
345
+ test_stem = Path(test_rel).stem.lower()
346
+ if test_stem == stem or test_stem in (f"test_{stem}", f"{stem}_test"):
347
+ covered_by = test_rel
348
+ break
349
+ if symbols and references_symbol(read_text(project_root / test_rel), symbols):
350
+ covered_by = test_rel
351
+ break
352
+ if covered_by is None and symbols:
353
+ if repo_tests is None:
354
+ repo_tests = iter_repo_tests(project_root)
355
+ for test_rel in repo_tests:
356
+ if test_rel in test_files:
357
+ continue
358
+ if references_symbol(read_text(project_root / test_rel), symbols):
359
+ covered_by = test_rel
360
+ break
361
+ if covered_by is None:
362
+ findings.append({
363
+ "severity": "fail", "code": "cr_code_without_test",
364
+ "path": relative,
365
+ "message": "Changed code file has no changed test and no existing test referencing its symbols; add a test or declare an exemption in status.json cr_allow_untested.",
366
+ })
367
+
368
+ # ---- Check 3: assertion balance ----------------------------------
369
+ added_assertions, removed_assertions = assertion_delta(
370
+ project_root, args.diff_ref, test_files, deleted,
371
+ )
372
+ if removed_assertions > added_assertions:
373
+ findings.append({
374
+ "severity": "fail", "code": "cr_assertion_net_decrease",
375
+ "message": "Test changes remove more assertions than they add; shrinking tests to reach green is not a passing change.",
376
+ "assertions_added": added_assertions,
377
+ "assertions_removed": removed_assertions,
378
+ })
379
+
380
+ # ---- Check 4: loop budget ----------------------------------------
381
+ max_files = args.max_files
382
+ if max_files is None:
383
+ max_files = status.get("cr_max_files") or DEFAULT_MAX_FILES
384
+ max_lines = args.max_lines
385
+ if max_lines is None:
386
+ max_lines = status.get("cr_max_lines") or DEFAULT_MAX_LINES
387
+ total_lines = sum(add + rem for add, rem in counts.values())
388
+ ratio = max(
389
+ len(all_files) / max_files if max_files else 0,
390
+ total_lines / max_lines if max_lines else 0,
391
+ )
392
+ budget = {
393
+ "files": len(all_files), "lines": total_lines,
394
+ "max_files": max_files, "max_lines": max_lines,
395
+ }
396
+ if ratio > 2:
397
+ findings.append({
398
+ "severity": "fail", "code": "cr_loop_budget_blown",
399
+ "message": "Diff is over 2x the loop budget for one change; split the spec into smaller changes instead of running one large loop.",
400
+ "budget": budget,
401
+ })
402
+ elif ratio > 1:
403
+ findings.append({
404
+ "severity": "warn", "code": "cr_loop_budget_exceeded",
405
+ "message": "Diff exceeds the loop budget for one change; consider splitting the spec into smaller changes.",
406
+ "budget": budget,
407
+ })
408
+
409
+ failed = any(finding.get("severity") == "fail" for finding in findings)
410
+ verdict = "failed" if failed else "passed"
411
+ proof = {
412
+ "ok": not failed,
413
+ "verdict": verdict,
414
+ "change_id": args.change_id,
415
+ "diff_ref": args.diff_ref,
416
+ "changed_files": all_files,
417
+ "code_files": code_files,
418
+ "test_files": test_files,
419
+ "spec_aligned_files": aligned_files,
420
+ "assertions": {"added": added_assertions, "removed": removed_assertions},
421
+ "budget": budget,
422
+ "findings": findings,
423
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
424
+ }
425
+
426
+ proof_dir = change_dir / "cr"
427
+ proof_dir.mkdir(parents=True, exist_ok=True)
428
+ proof_file = proof_dir / "review-0.json"
429
+ proof_file.write_text(json.dumps(proof, indent=2) + "\n", encoding="utf-8")
430
+
431
+ print(json.dumps({
432
+ "ok": proof["ok"],
433
+ "verdict": verdict,
434
+ "proof_file": str(proof_file),
435
+ "failures": sum(1 for f in findings if f.get("severity") == "fail"),
436
+ "warnings": sum(1 for f in findings if f.get("severity") == "warn"),
437
+ "failed_codes": [f.get("code") for f in findings if f.get("severity") == "fail"],
438
+ }))
439
+ sys.exit(0 if proof["ok"] else 1)
440
+
441
+
442
+ if __name__ == "__main__":
443
+ main()
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ """K1.artifacts.complete_check — Verify required change artifacts exist and are non-empty."""
3
+ import sys, os, json, argparse
4
+
5
+ def main():
6
+ p = argparse.ArgumentParser()
7
+ p.add_argument('--project-root', default='.')
8
+ p.add_argument('--change-id', required=True)
9
+ p.add_argument('--change-type', default='backend')
10
+ args = p.parse_args()
11
+
12
+ change_dir = os.path.join(args.project_root, 'specs', 'changes', args.change_id)
13
+ required = ['proposal.md', 'plan.md', 'tasks.md', 'status.json']
14
+ if args.change_type in ('frontend', 'full-stack'):
15
+ required.append('design.md')
16
+
17
+ missing = []
18
+ empty = []
19
+ for fname in required:
20
+ fpath = os.path.join(change_dir, fname)
21
+ if not os.path.exists(fpath):
22
+ missing.append(fname)
23
+ elif os.path.getsize(fpath) < 20:
24
+ empty.append(fname)
25
+
26
+ ok = len(missing) == 0 and len(empty) == 0
27
+ result = {
28
+ 'ok': ok,
29
+ 'atom': 'K1.artifacts.complete_check',
30
+ 'verdict': 'pass' if ok else 'fail',
31
+ 'missing': missing,
32
+ 'too_short': empty,
33
+ 'change_dir': change_dir,
34
+ }
35
+ print(json.dumps(result))
36
+ sys.exit(0 if ok else 1)
37
+
38
+ if __name__ == '__main__':
39
+ main()
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """K2.merge_snippets.apply — Standalone merge-snippets-only helper. Delegates to archive_change.py."""
3
+ import sys, os, subprocess
4
+
5
+ def main():
6
+ script = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'archive', 'scripts', 'archive_change.py'))
7
+ result = subprocess.run([sys.executable, script, '--merge-snippets-only'] + sys.argv[1:])
8
+ sys.exit(result.returncode)
9
+
10
+ if __name__ == '__main__':
11
+ main()
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """K3.handoff.refresh — Refresh HANDOFF.md. Delegates to B4 via scaffold_handoff.py."""
3
+ import sys, os, subprocess
4
+
5
+ def main():
6
+ script = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'handoff', 'scripts', 'scaffold_handoff.py'))
7
+ result = subprocess.run([sys.executable, script, '--refresh'] + sys.argv[1:])
8
+ # scaffold_handoff.py may not support --refresh; fallback to re-scaffold
9
+ if result.returncode != 0:
10
+ result = subprocess.run([sys.executable, script] + sys.argv[1:])
11
+ sys.exit(result.returncode)
12
+
13
+ if __name__ == '__main__':
14
+ main()
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """K4.mem.lesson_persist — Persist a lesson to cli-use-memory project scope. Delegates to C1.mem."""
3
+ import sys, os, subprocess, argparse, json
4
+
5
+ def candidate_mem_scripts():
6
+ env_script = os.environ.get('XMEM_MEM_SH')
7
+ if env_script:
8
+ yield env_script
9
+
10
+ here = os.path.dirname(__file__)
11
+ yield os.path.abspath(os.path.join(here, '..', '..', '..', 'as-xmem', 'mem.sh'))
12
+
13
+ home = os.path.expanduser('~')
14
+ for target in (
15
+ '.agents/skills/xmem/mem.sh',
16
+ '.codex/skills/xmem/mem.sh',
17
+ '.claude/skills/xmem/mem.sh',
18
+ '.hermes/skills/xmem/mem.sh',
19
+ '.openclaw/skills/xmem/mem.sh',
20
+ '.config/opencode/skills/xmem/mem.sh',
21
+ ):
22
+ yield os.path.join(home, target)
23
+
24
+ def find_mem_script():
25
+ for script in candidate_mem_scripts():
26
+ if script and os.path.isfile(script):
27
+ return script
28
+ return None
29
+
30
+ def main():
31
+ p = argparse.ArgumentParser()
32
+ p.add_argument('--project-root', default='.')
33
+ p.add_argument('--change-id', default=None)
34
+ p.add_argument('--lesson', default='')
35
+ p.add_argument('--context', default='')
36
+ args = p.parse_args()
37
+
38
+ lesson = (args.lesson or '').strip()
39
+ if not lesson or lesson.startswith('${'):
40
+ print(json.dumps({
41
+ 'ok': True,
42
+ 'atom': 'K4.mem.lesson_persist',
43
+ 'skipped': True,
44
+ 'reason': 'no lesson provided'
45
+ }))
46
+ return 0
47
+
48
+ mem_sh = find_mem_script()
49
+ if not mem_sh:
50
+ print(json.dumps({
51
+ 'ok': True,
52
+ 'atom': 'K4.mem.lesson_persist',
53
+ 'skipped': True,
54
+ 'reason': 'xmem mem.sh not found; install xmem to enable lesson persistence'
55
+ }))
56
+ return 0
57
+
58
+ content = f'{lesson}'
59
+ if args.context:
60
+ content += f'\nContext: {args.context}'
61
+ result = subprocess.run(['bash', mem_sh, 'lesson:project', content])
62
+ return result.returncode
63
+
64
+ if __name__ == '__main__':
65
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+ """K5.archive.publish — Standalone terminal status helper, not the workflow archive publisher."""
3
+ import sys, os, json, argparse
4
+ from datetime import datetime, timezone
5
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
6
+ from common import load_status, save_status
7
+
8
+ def main():
9
+ p = argparse.ArgumentParser()
10
+ p.add_argument('--project-root', default='.')
11
+ p.add_argument('--change-id', required=True)
12
+ args = p.parse_args()
13
+
14
+ path, status = load_status(args.project_root, args.change_id)
15
+ status['archival_status'] = 'archived'
16
+ status['archived_at'] = datetime.now(timezone.utc).isoformat()
17
+ save_status(path, status)
18
+ print(json.dumps({'ok': True, 'archived_at': status['archived_at']}))
19
+
20
+ if __name__ == '__main__':
21
+ main()
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ K6.aha.merge — Merge durable insights from change workspace into root AHA.md.
4
+ Reads specs/changes/<id>/merge-aha.md if present and appends unique entries.
5
+ Idempotent: uses date+title as dedup key.
6
+ """
7
+ import sys, os, json, argparse, re
8
+ from datetime import datetime, timezone
9
+
10
+ def main():
11
+ p = argparse.ArgumentParser()
12
+ p.add_argument('--project-root', default='.')
13
+ p.add_argument('--change-id', required=True)
14
+ args = p.parse_args()
15
+
16
+ aha_path = os.path.join(args.project_root, 'AHA.md')
17
+ merge_source = os.path.join(args.project_root, 'specs', 'changes', args.change_id, 'merge-aha.md')
18
+
19
+ if not os.path.exists(merge_source):
20
+ print(json.dumps({'ok': True, 'merged': 0, 'message': 'No merge-aha.md found, skipping'}))
21
+ sys.exit(0)
22
+
23
+ merge_content = open(merge_source).read().strip()
24
+ if not merge_content:
25
+ print(json.dumps({'ok': True, 'merged': 0, 'message': 'merge-aha.md is empty'}))
26
+ sys.exit(0)
27
+
28
+ existing = open(aha_path).read() if os.path.exists(aha_path) else ''
29
+
30
+ # Simple dedup: check if the first line of merge_content already appears in AHA.md
31
+ first_line = merge_content.splitlines()[0]
32
+ if first_line in existing:
33
+ print(json.dumps({'ok': True, 'merged': 0, 'message': 'Entry already present (dedup by first line)'}))
34
+ sys.exit(0)
35
+
36
+ with open(aha_path, 'a') as f:
37
+ f.write(f'\n\n{merge_content}\n')
38
+
39
+ print(json.dumps({'ok': True, 'merged': 1, 'aha_path': aha_path}))
40
+
41
+ if __name__ == '__main__':
42
+ main()