@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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +102 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7208 -0
- package/docs/adoption/README.md +128 -0
- package/docs/adoption/openflow-release-hardening.md +61 -0
- package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
- package/docs/compatibility.md +52 -0
- package/docs/competitive-benchmark.md +108 -0
- package/docs/corps-operator-guide.md +75 -0
- package/docs/demo-proof.md +126 -0
- package/docs/examples-gallery.md +115 -0
- package/docs/fixtures/tracker-item.json +7 -0
- package/docs/goal-vs-codex.md +129 -0
- package/docs/gstack-comparison.md +88 -0
- package/docs/install-upgrade.md +133 -0
- package/docs/integrations.md +49 -0
- package/docs/launch-demo.md +123 -0
- package/docs/launch-dossier.md +180 -0
- package/docs/methodology.md +149 -0
- package/docs/npm-publish-handoff.md +83 -0
- package/docs/openspec-migration.md +106 -0
- package/docs/public-benchmark.md +68 -0
- package/docs/public-release.md +145 -0
- package/docs/quality-assessment.md +139 -0
- package/docs/quickstart.md +156 -0
- package/docs/reviewer-guide.md +43 -0
- package/docs/spec-kit-benchmark.md +73 -0
- package/docs/super-assistant-comparison.md +106 -0
- package/docs/superpowers-comparison.md +82 -0
- package/docs/team-adoption.md +123 -0
- package/docs/tooling-matrix.md +91 -0
- package/docs/walkthrough.md +76 -0
- package/docs/workflow-manual.md +1125 -0
- package/package.json +83 -4
- package/prompts/pencil-draft.md +46 -0
- package/prompts/pencil-refine.md +46 -0
- package/prompts/phase-artifact.md +24 -0
- package/prompts/phase-execution.md +32 -0
- package/schemas/competitor-reconstruction-contract.schema.json +91 -0
- package/schemas/llm-design-review-aggregate.schema.json +83 -0
- package/schemas/visual-benchmark.schema.json +978 -0
- package/schemas/workflow.schema.json +100 -0
- package/scripts/run-tests.mjs +23 -0
- package/scripts/takein_digest.py +139 -0
- package/specs/README.md +118 -0
- package/specs/architecture.md +36 -0
- package/specs/product.md +33 -0
- package/specs/workflow.md +349 -0
- package/src/agent_team/atoms/codex-app-bridge.js +8 -0
- package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
- package/src/agent_team/atoms/design/contract-freeze.js +8 -0
- package/src/agent_team/atoms/design/generation-contract.js +442 -0
- package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
- package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
- package/src/agent_team/atoms/patch-challenge.js +19 -0
- package/src/agent_team/atoms/pencil/accept.js +9 -0
- package/src/agent_team/atoms/pencil/draft.js +365 -0
- package/src/agent_team/atoms/pencil/refine.js +229 -0
- package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
- package/src/agent_team/atoms/team-run.js +380 -0
- package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
- package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
- package/src/agent_team/atoms/visual/benchmark.js +1652 -0
- package/src/agent_team/atoms/visual/design-selection.js +23 -0
- package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
- package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
- package/src/agent_team/atoms/visual/image-diff.js +186 -0
- package/src/agent_team/atoms/visual/layout-competition.js +8 -0
- package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
- package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
- package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
- package/src/agent_team/atoms/visual/review.js +77 -0
- package/src/agent_team/atoms/visual/visual-report.js +197 -0
- package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
- package/src/agent_team/common.js +311 -0
- package/src/core/atoms/agent-invoke.js +684 -0
- package/src/core/atoms/event/ingest.js +30 -0
- package/src/core/atoms/experience/digest.js +55 -0
- package/src/core/atoms/gate/artifact-verify.js +83 -0
- package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
- package/src/core/atoms/gate/evidence-collect.js +64 -0
- package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
- package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
- package/src/core/atoms/gate/workbench.js +79 -0
- package/src/core/atoms/intake/classify.js +15 -0
- package/src/core/atoms/intake/design-skill-select.js +16 -0
- package/src/core/atoms/intake/preview-or-refine.js +19 -0
- package/src/core/atoms/intake/recommend.js +20 -0
- package/src/core/atoms/intake/research-same-category.js +19 -0
- package/src/core/atoms/intervention/create.js +30 -0
- package/src/core/atoms/memory/manage.js +27 -0
- package/src/core/atoms/memory/mount.js +13 -0
- package/src/core/atoms/mission/create.js +19 -0
- package/src/core/atoms/mission/list.js +14 -0
- package/src/core/atoms/mission/show.js +26 -0
- package/src/core/atoms/notify/checkpoint.js +46 -0
- package/src/core/atoms/phase-run/complete.js +42 -0
- package/src/core/atoms/phase-run/fail.js +40 -0
- package/src/core/atoms/phase-run/persist.js +39 -0
- package/src/core/atoms/phase-run/start.js +30 -0
- package/src/core/atoms/plugin/run.js +76 -0
- package/src/core/atoms/policy/gate.js +90 -0
- package/src/core/atoms/policy/resolve.js +11 -0
- package/src/core/atoms/storage/sync.js +57 -0
- package/src/core/atoms/workflow/advance.js +25 -0
- package/src/core/atoms/workflow/guard.js +127 -0
- package/src/core/atoms/workflow/state-load.js +37 -0
- package/src/core/bindings.js +37 -0
- package/src/core/change-artifacts.js +134 -0
- package/src/core/competitive-score.js +265 -0
- package/src/core/concurrency-policy.js +35 -0
- package/src/core/execution-log.js +108 -0
- package/src/core/intake-recommendation.js +134 -0
- package/src/core/memory-state.js +62 -0
- package/src/core/mission-state.js +780 -0
- package/src/core/pending-gates.js +66 -0
- package/src/core/phase-cost.js +116 -0
- package/src/core/phase-run-read.js +27 -0
- package/src/core/policy-overlay.js +180 -0
- package/src/core/state-store.js +201 -0
- package/src/core/workflow-executor.js +998 -0
- package/src/core/workflow-integrity.js +111 -0
- package/src/core/workflow-loader.js +163 -0
- package/src/core/workflow-rerun.js +84 -0
- package/src/core/workflow-state.js +119 -0
- package/src/server.js +1307 -0
- package/workflows/corps.yaml +479 -0
- package/workflows/yolo.yaml +233 -0
- package/xflow/README.md +76 -0
- package/xflow/aha/SKILL.md +82 -0
- package/xflow/aha/scripts/append_aha.py +50 -0
- package/xflow/archive/scripts/archive_change.py +338 -0
- package/xflow/archive/templates/merge-design.md +54 -0
- package/xflow/archive/templates/merge-workflow.md +26 -0
- package/xflow/atoms/__init__.py +6 -0
- package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
- package/xflow/atoms/a2_branch_create_and_link.py +42 -0
- package/xflow/atoms/a3_repo_doctor.py +11 -0
- package/xflow/atoms/a4_project_set_status.py +11 -0
- package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
- package/xflow/atoms/a6_pr_create.py +72 -0
- package/xflow/atoms/b1_change_scaffold.py +11 -0
- package/xflow/atoms/b2_status_read.py +16 -0
- package/xflow/atoms/b2b_status_write.py +20 -0
- package/xflow/atoms/b3_status_transition.py +27 -0
- package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
- package/xflow/atoms/b5_aha_append.py +11 -0
- package/xflow/atoms/b6_takein_digest.py +11 -0
- package/xflow/atoms/b6b_lookback_digest.py +11 -0
- package/xflow/atoms/b7_change_mission_bind.py +43 -0
- package/xflow/atoms/c1_mem.py +54 -0
- package/xflow/atoms/e6_gate_local_precheck.py +23 -0
- package/xflow/atoms/h1_design_lite_gate.py +74 -0
- package/xflow/atoms/i6a_tdd_run.py +104 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
- package/xflow/atoms/j1_tests_run.py +129 -0
- package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
- package/xflow/atoms/j4b_spec_delta_review.py +316 -0
- package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
- package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
- package/xflow/atoms/j6_cr_review.py +443 -0
- package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
- package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
- package/xflow/atoms/k3_handoff_refresh.py +14 -0
- package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
- package/xflow/atoms/k5_archive_publish.py +21 -0
- package/xflow/atoms/k6_aha_merge.py +42 -0
- package/xflow/atoms/tdd_lib.py +747 -0
- package/xflow/corps/SKILL.md +109 -0
- package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
- package/xflow/goal/SKILL.md +150 -0
- package/xflow/handoff/SKILL.md +95 -0
- package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
- package/xflow/lookback/SKILL.md +66 -0
- package/xflow/lookback/scripts/lookback_digest.py +46 -0
- package/xflow/openissue/scripts/open_issue_flow.py +438 -0
- package/xflow/plan/SKILL.md +38 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +480 -0
- package/xflow/scripts/doctor.py +278 -0
- package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
- package/xflow/takein/SKILL.md +81 -0
- package/xflow/takein/scripts/takein_digest.py +88 -0
- package/xflow/yolo/SKILL.md +66 -0
- package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
- package/xflow/yolo/scripts/yolo_gate.py +312 -0
- 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()
|