workflow-toolkit 1.0.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/.agents/skills/ponytail/SKILL.md +111 -0
- package/.agents/skills/ponytail-audit/SKILL.md +37 -0
- package/.agents/skills/ponytail-debt/SKILL.md +41 -0
- package/.agents/skills/ponytail-gain/SKILL.md +47 -0
- package/.agents/skills/ponytail-help/SKILL.md +70 -0
- package/.agents/skills/ponytail-review/SKILL.md +52 -0
- package/.agents/skills/prompt-review/SKILL.md +60 -0
- package/.agents/skills/wtk/SKILL.md +64 -0
- package/.agents/skills/wtk/references/artifacts.md +82 -0
- package/.agents/skills/wtk/references/evidence.md +68 -0
- package/.agents/skills/wtk/references/git.md +75 -0
- package/.agents/skills/wtk/references/test-contract.md +76 -0
- package/.agents/skills/wtk/references/validation.md +127 -0
- package/.agents/skills/wtk-config/SKILL.md +102 -0
- package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
- package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
- package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
- package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
- package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
- package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
- package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
- package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
- package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
- package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
- package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
- package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
- package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
- package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
- package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
- package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
- package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
- package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
- package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
- package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
- package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
- package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
- package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
- package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
- package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
- package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
- package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
- package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
- package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
- package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
- package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
- package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
- package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
- package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
- package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
- package/.agents/skills/wtk-discover/SKILL.md +270 -0
- package/.agents/skills/wtk-discover/references/document-format.md +149 -0
- package/.agents/skills/wtk-implement/SKILL.md +95 -0
- package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
- package/.agents/skills/wtk-implement/references/screens.md +129 -0
- package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
- package/.agents/skills/wtk-implement/references/verify.md +297 -0
- package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
- package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
- package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
- package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
- package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
- package/.agents/skills/wtk-lean/NOTICE.md +18 -0
- package/.agents/skills/wtk-lean/SKILL.md +137 -0
- package/.agents/skills/wtk-lean/references/build.md +168 -0
- package/.agents/skills/wtk-lean/references/checks.md +253 -0
- package/.agents/skills/wtk-lean/references/memory.md +156 -0
- package/.agents/skills/wtk-lean/references/plan.md +514 -0
- package/.agents/skills/wtk-lean/references/verify.md +336 -0
- package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
- package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
- package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
- package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
- package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
- package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
- package/.agents/skills/wtk-plan/SKILL.md +102 -0
- package/.agents/skills/wtk-plan/references/document-format.md +222 -0
- package/.agents/skills/wtk-qa/SKILL.md +14 -0
- package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
- package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
- package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
- package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
- package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
- package/.agents/skills/wtk-ship/SKILL.md +49 -0
- package/.agents/skills/wtk-ship/remediation.py +170 -0
- package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
- package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
- package/.wtk.toml.example +93 -0
- package/AGENTS.md +129 -0
- package/NOTICE.md +12 -0
- package/README.md +440 -0
- package/bin/wtk.js +25 -0
- package/docs/toolkit/README.md +56 -0
- package/docs/toolkit/decisions.md +61 -0
- package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
- package/docs/toolkit/guidelines/DX.md +67 -0
- package/docs/toolkit/guidelines/FRONTEND.md +73 -0
- package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
- package/docs/toolkit/guidelines/MODELING.md +134 -0
- package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
- package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
- package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
- package/docs/toolkit/guidelines/SECURITY.md +160 -0
- package/docs/toolkit/guidelines/UI-UX.md +117 -0
- package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
- package/docs/toolkit/guidelines.md +50 -0
- package/docs/toolkit/loop.md +77 -0
- package/docs/toolkit/purpose.md +51 -0
- package/docs/toolkit/repository-intelligence.md +53 -0
- package/docs/toolkit/reviews.md +77 -0
- package/knowledge/AGENTS.md +316 -0
- package/knowledge/raw/README.md +17 -0
- package/package.json +74 -0
- package/scripts/install_security_skills.py +816 -0
- package/scripts/installer/engine.js +254 -0
- package/scripts/installer/knowledge.js +28 -0
- package/scripts/installer/packets.js +77 -0
- package/scripts/installer/terminal.js +98 -0
- package/scripts/installer/transaction.js +75 -0
- package/skills-lock.json +92 -0
- package/templates/adoption/agents/core.md +23 -0
- package/templates/adoption/agents/quality.md +3 -0
- package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
- package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/design/index.md +3 -0
- package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
- package/templates/adoption/knowledge/wiki/index.md +3 -0
- package/templates/adoption/knowledge/wiki/log.md +3 -0
- package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/product/index.md +3 -0
- package/templates/adoption/knowledge/wiki/research/index.md +3 -0
- package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure remediation-stall normalization and transition helper.
|
|
3
|
+
|
|
4
|
+
The executable JSON protocol reads one object from stdin and writes the next
|
|
5
|
+
state to stdout. No repository, process, or remote authority is involved.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
DEFAULT_STALL_ATTEMPTS = 3
|
|
17
|
+
_LOCATION = re.compile(
|
|
18
|
+
r"(?P<path>(?:(?:[A-Za-z]:)?[\\/]|(?<!\S))[^()\r\n]*?"
|
|
19
|
+
r"[^/\\\s:]+\.[^/\\\s:()]+)(?::\d+(?::\d+)?|\(\d+,\d+\):?)"
|
|
20
|
+
)
|
|
21
|
+
_PATH = re.compile(
|
|
22
|
+
r"(?P<path>(?:(?:[A-Za-z]:)?[\\/]|(?<!\S))[^()\r\n]*?"
|
|
23
|
+
r"[^/\\\s:]+\.[^/\\\s:()]+)(?=\s*(?:>|$))"
|
|
24
|
+
)
|
|
25
|
+
_TIMING = re.compile(r"\s*\(?\d+(?:\.\d+)?\s*(?:ms|s)\)?", re.IGNORECASE)
|
|
26
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_stall_attempts(value: Any) -> int:
|
|
30
|
+
"""Return the strict bound: absent is 3, zero is unbounded."""
|
|
31
|
+
if value is None:
|
|
32
|
+
return DEFAULT_STALL_ATTEMPTS
|
|
33
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
34
|
+
raise ValueError("stall_attempts must be a non-negative integer")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_test_name(value: Any) -> str:
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
raise ValueError("failing test names must be strings")
|
|
41
|
+
text = _TIMING.sub("", value.strip())
|
|
42
|
+
|
|
43
|
+
def strip_location(match: re.Match[str]) -> str:
|
|
44
|
+
path = match.group("path").replace("\\", "/").rsplit("/", 1)[-1]
|
|
45
|
+
return f"{path} >" if match.group(0).endswith(":") else path
|
|
46
|
+
|
|
47
|
+
text = _LOCATION.sub(strip_location, text)
|
|
48
|
+
text = _PATH.sub(strip_location, text)
|
|
49
|
+
return _WHITESPACE.sub(" ", text).strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_failing_tests(names: Iterable[Any]) -> list[str]:
|
|
53
|
+
"""Trim, de-time, de-path, deduplicate, and sort test identifiers."""
|
|
54
|
+
if isinstance(names, (str, bytes)):
|
|
55
|
+
raise ValueError("failing tests must be an array")
|
|
56
|
+
values = {_normalize_test_name(name) for name in names}
|
|
57
|
+
values.discard("")
|
|
58
|
+
return sorted(values, key=lambda value: (value.casefold(), value))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_fixes(fixes: Iterable[Any]) -> list[str]:
|
|
62
|
+
if isinstance(fixes, (str, bytes)):
|
|
63
|
+
raise ValueError("fixes_tried must be an array")
|
|
64
|
+
result: list[str] = []
|
|
65
|
+
seen: set[str] = set()
|
|
66
|
+
for fix in fixes:
|
|
67
|
+
if not isinstance(fix, str):
|
|
68
|
+
raise ValueError("fixes_tried must contain strings")
|
|
69
|
+
value = _WHITESPACE.sub(" ", fix.strip())
|
|
70
|
+
if value and value not in seen:
|
|
71
|
+
seen.add(value)
|
|
72
|
+
result.append(value)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _previous_tests(previous: Mapping[str, Any]) -> tuple[bool, list[str]]:
|
|
77
|
+
attempt_count = int(previous.get("attempt_count", 0))
|
|
78
|
+
if attempt_count == 0:
|
|
79
|
+
return False, []
|
|
80
|
+
if "minimum_failing_tests" not in previous:
|
|
81
|
+
raise ValueError("previous state is missing minimum_failing_tests")
|
|
82
|
+
return True, normalize_failing_tests(previous["minimum_failing_tests"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def transition_remediation(
|
|
86
|
+
previous: Mapping[str, Any] | None,
|
|
87
|
+
failing_tests: Iterable[Any],
|
|
88
|
+
*,
|
|
89
|
+
stall_attempts: Any = None,
|
|
90
|
+
gate_available: bool = True,
|
|
91
|
+
fixes_tried: Iterable[Any] = (),
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Advance one post-cap remediation attempt without side effects."""
|
|
94
|
+
previous_state: Mapping[str, Any] = previous or {}
|
|
95
|
+
threshold = normalize_stall_attempts(stall_attempts)
|
|
96
|
+
current = normalize_failing_tests(failing_tests)
|
|
97
|
+
fixes = _normalize_fixes(previous_state.get("fixes_tried", []))
|
|
98
|
+
for fix in _normalize_fixes(fixes_tried):
|
|
99
|
+
if fix not in fixes:
|
|
100
|
+
fixes.append(fix)
|
|
101
|
+
attempt_count = int(previous_state.get("attempt_count", 0)) + 1
|
|
102
|
+
prior_stalls = int(previous_state.get("consecutive_stalls", 0))
|
|
103
|
+
had_baseline, prior_tests = _previous_tests(previous_state)
|
|
104
|
+
minimum_tests = prior_tests
|
|
105
|
+
minimum_count = len(minimum_tests)
|
|
106
|
+
|
|
107
|
+
state: dict[str, Any] = {
|
|
108
|
+
"stall_attempts": threshold,
|
|
109
|
+
"failing_tests": current,
|
|
110
|
+
"failing_signature": " | ".join(current),
|
|
111
|
+
"minimum_failing_tests": minimum_tests,
|
|
112
|
+
"attempt_count": attempt_count,
|
|
113
|
+
"consecutive_stalls": prior_stalls,
|
|
114
|
+
"minimum_failing_count": minimum_count,
|
|
115
|
+
"fixes_tried": fixes,
|
|
116
|
+
"halted": False,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if not gate_available:
|
|
120
|
+
state.update({"status": "halted", "reason": "scoped_gate_unavailable", "halted": True})
|
|
121
|
+
state["report"] = {
|
|
122
|
+
"reason": state["reason"],
|
|
123
|
+
"failing_signature": state["failing_signature"],
|
|
124
|
+
"attempt_count": state["attempt_count"],
|
|
125
|
+
"fixes_tried": state["fixes_tried"],
|
|
126
|
+
}
|
|
127
|
+
return state
|
|
128
|
+
|
|
129
|
+
if not had_baseline or set(current) < set(minimum_tests):
|
|
130
|
+
state.update({
|
|
131
|
+
"status": "progress",
|
|
132
|
+
"reason": "smaller_failing_set" if had_baseline else "initial_failure_set",
|
|
133
|
+
"consecutive_stalls": 0,
|
|
134
|
+
"minimum_failing_tests": current,
|
|
135
|
+
"minimum_failing_count": len(current),
|
|
136
|
+
})
|
|
137
|
+
return state
|
|
138
|
+
|
|
139
|
+
stalls = prior_stalls + 1
|
|
140
|
+
state.update({"status": "stalled", "reason": "failing_set_not_smaller", "consecutive_stalls": stalls})
|
|
141
|
+
if threshold and stalls >= threshold:
|
|
142
|
+
state.update({"status": "halted", "reason": "stall_threshold_reached", "halted": True})
|
|
143
|
+
state["report"] = {
|
|
144
|
+
"reason": state["reason"],
|
|
145
|
+
"failing_signature": state["failing_signature"],
|
|
146
|
+
"attempt_count": state["attempt_count"],
|
|
147
|
+
"fixes_tried": state["fixes_tried"],
|
|
148
|
+
}
|
|
149
|
+
return state
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
try:
|
|
154
|
+
payload = json.load(sys.stdin)
|
|
155
|
+
result = transition_remediation(
|
|
156
|
+
payload.get("previous"),
|
|
157
|
+
payload.get("failing_tests", []),
|
|
158
|
+
stall_attempts=payload.get("stall_attempts"),
|
|
159
|
+
gate_available=payload.get("gate_available", True),
|
|
160
|
+
fixes_tried=payload.get("fixes_tried", []),
|
|
161
|
+
)
|
|
162
|
+
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as error:
|
|
163
|
+
print(f"error: {error}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
print(json.dumps(result, sort_keys=True))
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Close a verified Workflow Toolkit feature after explicit fact promotion.
|
|
3
|
+
|
|
4
|
+
Promotion is semantic work performed by the agent and human, so this helper only
|
|
5
|
+
checks the receipt and removes the transient feature directory. It never writes
|
|
6
|
+
knowledge, decisions, product promises, or architecture documentation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def feature_dir(root: Path, feature: str) -> Path:
|
|
23
|
+
if not SLUG.fullmatch(feature):
|
|
24
|
+
raise ValueError("feature must be a lowercase slug")
|
|
25
|
+
root = root.resolve()
|
|
26
|
+
specs = root / ".specs"
|
|
27
|
+
features = specs / "features"
|
|
28
|
+
target = features / feature
|
|
29
|
+
for parent in (specs, features):
|
|
30
|
+
if parent.is_symlink():
|
|
31
|
+
raise ValueError(f"cleanup path must not be a symlink: {parent.relative_to(root)}")
|
|
32
|
+
if not parent.is_dir():
|
|
33
|
+
raise ValueError(f"feature directory is missing: {features.relative_to(root)}")
|
|
34
|
+
if target.is_symlink():
|
|
35
|
+
raise ValueError(f"cleanup path must not be a symlink: {target.relative_to(root)}")
|
|
36
|
+
if target.parent != features or target == features or target.resolve().parent != features:
|
|
37
|
+
raise ValueError("feature path must remain under .specs/features")
|
|
38
|
+
return target
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def close_feature(root: Path, feature: str, *, promoted: bool = False) -> Path:
|
|
42
|
+
"""Delete one verified feature directory after a recorded promotion."""
|
|
43
|
+
if not promoted:
|
|
44
|
+
raise ValueError("promotion receipt is required before cleanup")
|
|
45
|
+
root = root.resolve()
|
|
46
|
+
target = feature_dir(root, feature)
|
|
47
|
+
verification = target / "verification.md"
|
|
48
|
+
if not verification.is_file():
|
|
49
|
+
raise ValueError("verification.md is required before cleanup")
|
|
50
|
+
checks = target / "checks.md"
|
|
51
|
+
if not checks.is_file():
|
|
52
|
+
raise ValueError("checks.md is required before cleanup")
|
|
53
|
+
validator = root / ".agents" / "skills" / "wtk-lean" / "scripts" / "validate_verification.py"
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
[sys.executable, str(validator), str(target.resolve()), "--root", str(root)],
|
|
56
|
+
cwd=root,
|
|
57
|
+
text=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
raise ValueError(result.stderr.strip() or result.stdout.strip() or "verification gate failed")
|
|
63
|
+
shutil.rmtree(target)
|
|
64
|
+
return target
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
69
|
+
parser.add_argument("feature")
|
|
70
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
71
|
+
parser.add_argument("--promoted", action="store_true", help="confirm durable facts were promoted")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
try:
|
|
74
|
+
removed = close_feature(args.root, args.feature, promoted=args.promoted)
|
|
75
|
+
except (OSError, ValueError) as exc:
|
|
76
|
+
print(f"close_feature: {exc}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
print(json.dumps({"feature": args.feature, "removed": str(removed.relative_to(args.root.resolve()))}))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Persist immutable blocker fingerprints and append-only audit generations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_SKILLS_ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
sys.path.insert(0, str(_SKILLS_ROOT / "wtk-ship"))
|
|
19
|
+
import remediation
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(_SKILLS_ROOT / "wtk-config" / "scripts"))
|
|
22
|
+
import workflow_config
|
|
23
|
+
|
|
24
|
+
MAX_FAILURES = 3
|
|
25
|
+
GENERATION_STATUSES = {"open", "halted", "closed"}
|
|
26
|
+
REMEDIATION_FIELDS = (
|
|
27
|
+
"failing_tests", "failing_signature", "minimum_failing_tests", "minimum_failing_count",
|
|
28
|
+
"consecutive_stalls", "attempt_count", "fixes_tried",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize(value: str) -> str:
|
|
33
|
+
return re.sub(r"\s+", " ", value.strip().lower())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fingerprint(requirement: str, root_cause: str, failure_path: str) -> str:
|
|
37
|
+
material = "\0".join(_normalize(value) for value in (requirement, root_cause, failure_path))
|
|
38
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def state_path(root: Path, feature: str) -> Path:
|
|
42
|
+
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", feature):
|
|
43
|
+
raise ValueError("invalid feature")
|
|
44
|
+
resolved_root = Path(root).resolve()
|
|
45
|
+
feature_dir = (resolved_root / ".specs" / "features" / feature).resolve()
|
|
46
|
+
try:
|
|
47
|
+
feature_dir.relative_to(resolved_root)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise ValueError("feature path escapes root") from exc
|
|
50
|
+
return feature_dir / "review-fingerprints.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _halt_event(generation: int, failures: int) -> dict[str, Any]:
|
|
54
|
+
return {"generation": generation, "failed_remediations": failures, "status": "halted"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _generation(number: int, failures: int, status: str, **extra: Any) -> dict[str, Any]:
|
|
58
|
+
value: dict[str, Any] = {
|
|
59
|
+
"generation": number,
|
|
60
|
+
"failed_remediations": failures,
|
|
61
|
+
"status": status,
|
|
62
|
+
"failing_tests": [],
|
|
63
|
+
"failing_signature": "",
|
|
64
|
+
"minimum_failing_tests": [],
|
|
65
|
+
"minimum_failing_count": 0,
|
|
66
|
+
"consecutive_stalls": 0,
|
|
67
|
+
"attempt_count": 0,
|
|
68
|
+
"fixes_tried": [],
|
|
69
|
+
}
|
|
70
|
+
value.update(extra)
|
|
71
|
+
if status == "halted":
|
|
72
|
+
value.setdefault("halt_event", _halt_event(number, failures))
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _legacy_generation(entry: dict[str, Any]) -> dict[str, Any]:
|
|
77
|
+
failures = entry.get("failed_remediations")
|
|
78
|
+
status = entry.get("status")
|
|
79
|
+
if type(failures) is not int or failures < 0 or status not in GENERATION_STATUSES:
|
|
80
|
+
raise ValueError("invalid convergence entry")
|
|
81
|
+
if status == "halted" and failures < MAX_FAILURES:
|
|
82
|
+
raise ValueError("halted convergence entry has too few failures")
|
|
83
|
+
if status == "open" and failures >= MAX_FAILURES:
|
|
84
|
+
raise ValueError("open convergence entry has too many failures")
|
|
85
|
+
if status == "closed" and failures >= MAX_FAILURES:
|
|
86
|
+
raise ValueError("closed convergence entry has too many failures")
|
|
87
|
+
return _generation(1, failures, status)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _valid_authorization(reference: str) -> bool:
|
|
91
|
+
return bool(
|
|
92
|
+
reference and reference == reference.strip() and reference.startswith(".specs/")
|
|
93
|
+
and "#" in reference and ".." not in reference and "\\" not in reference
|
|
94
|
+
and not Path(reference).is_absolute()
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_generation(generation: Any, expected_number: int) -> dict[str, Any]:
|
|
99
|
+
if not isinstance(generation, dict) or generation.get("generation") != expected_number:
|
|
100
|
+
raise ValueError("non-contiguous audit generations")
|
|
101
|
+
failures = generation.get("failed_remediations")
|
|
102
|
+
status = generation.get("status")
|
|
103
|
+
if type(failures) is not int or failures < 0 or status not in GENERATION_STATUSES:
|
|
104
|
+
raise ValueError("invalid audit generation")
|
|
105
|
+
for field in REMEDIATION_FIELDS:
|
|
106
|
+
if field not in generation:
|
|
107
|
+
if field == "failing_tests":
|
|
108
|
+
generation[field] = []
|
|
109
|
+
elif field == "minimum_failing_tests":
|
|
110
|
+
generation[field] = list(generation.get("failing_tests", []))
|
|
111
|
+
elif field == "failing_signature":
|
|
112
|
+
generation[field] = ""
|
|
113
|
+
elif field == "fixes_tried":
|
|
114
|
+
generation[field] = []
|
|
115
|
+
else:
|
|
116
|
+
generation[field] = 0
|
|
117
|
+
if not isinstance(generation["failing_tests"], list) or any(not isinstance(value, str) for value in generation["failing_tests"]):
|
|
118
|
+
raise ValueError("invalid remediation failing tests")
|
|
119
|
+
if not isinstance(generation["minimum_failing_tests"], list) or any(not isinstance(value, str) for value in generation["minimum_failing_tests"]):
|
|
120
|
+
raise ValueError("invalid remediation minimum failing tests")
|
|
121
|
+
if not isinstance(generation["failing_signature"], str):
|
|
122
|
+
raise ValueError("invalid remediation failing signature")
|
|
123
|
+
for field in ("minimum_failing_count", "consecutive_stalls", "attempt_count"):
|
|
124
|
+
if type(generation[field]) is not int or generation[field] < 0:
|
|
125
|
+
raise ValueError("invalid remediation counters")
|
|
126
|
+
if not isinstance(generation["fixes_tried"], list) or any(not isinstance(value, str) for value in generation["fixes_tried"]):
|
|
127
|
+
raise ValueError("invalid remediation fixes")
|
|
128
|
+
halt_reason = generation.get("halt_reason")
|
|
129
|
+
if halt_reason is not None and halt_reason not in {"stall_threshold_reached", "scoped_gate_unavailable"}:
|
|
130
|
+
raise ValueError("invalid remediation halt reason")
|
|
131
|
+
if status == "halted":
|
|
132
|
+
if halt_reason == "stall_threshold_reached" and generation["consecutive_stalls"] < 1:
|
|
133
|
+
raise ValueError("halted audit generation has no recorded stall")
|
|
134
|
+
if failures < MAX_FAILURES and halt_reason not in {"stall_threshold_reached", "scoped_gate_unavailable"}:
|
|
135
|
+
raise ValueError("halted audit generation has no valid halt reason")
|
|
136
|
+
event = generation.get("halt_event")
|
|
137
|
+
if not isinstance(event, dict) or event != _halt_event(expected_number, failures):
|
|
138
|
+
raise ValueError("invalid halt event")
|
|
139
|
+
elif "halt_event" in generation or halt_reason is not None:
|
|
140
|
+
raise ValueError("halt event on non-halted generation")
|
|
141
|
+
if "authorization_ref" in generation and (
|
|
142
|
+
not isinstance(generation["authorization_ref"], str)
|
|
143
|
+
or not _valid_authorization(generation["authorization_ref"])
|
|
144
|
+
):
|
|
145
|
+
raise ValueError("invalid authorization reference")
|
|
146
|
+
return generation
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_entry(key: str, value: Any) -> dict[str, Any]:
|
|
150
|
+
if not isinstance(value, dict) or value.get("fingerprint") != key:
|
|
151
|
+
raise ValueError("invalid convergence entry")
|
|
152
|
+
required = ("requirement", "root_cause", "failure_path")
|
|
153
|
+
if any(not isinstance(value.get(field), str) or not value[field].strip() for field in required):
|
|
154
|
+
raise ValueError("invalid convergence entry")
|
|
155
|
+
normalized = dict(value)
|
|
156
|
+
for field in required:
|
|
157
|
+
normalized[field] = _normalize(normalized[field])
|
|
158
|
+
generations = normalized.get("generations")
|
|
159
|
+
if generations is None:
|
|
160
|
+
generations = [_legacy_generation(normalized)]
|
|
161
|
+
elif not isinstance(generations, list) or not generations:
|
|
162
|
+
raise ValueError("invalid audit generations")
|
|
163
|
+
generations = [_validate_generation(item, index) for index, item in enumerate(generations, 1)]
|
|
164
|
+
current_generation = normalized.get("current_generation", len(generations))
|
|
165
|
+
if current_generation != len(generations):
|
|
166
|
+
raise ValueError("invalid current generation")
|
|
167
|
+
cumulative = sum(item["failed_remediations"] for item in generations)
|
|
168
|
+
if type(normalized.get("failed_remediations")) is not int or normalized.get("failed_remediations") != cumulative:
|
|
169
|
+
raise ValueError("inconsistent cumulative failures")
|
|
170
|
+
if normalized.get("status") != generations[-1]["status"]:
|
|
171
|
+
raise ValueError("inconsistent convergence status")
|
|
172
|
+
if any(item["status"] == "closed" for item in generations[:-1]):
|
|
173
|
+
raise ValueError("closed audit generation cannot be resumed")
|
|
174
|
+
normalized.update({"current_generation": current_generation, "generations": generations})
|
|
175
|
+
return normalized
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load(path: Path, feature: str) -> dict[str, Any]:
|
|
179
|
+
if not path.exists():
|
|
180
|
+
return {"version": 2, "feature": feature, "fingerprints": {}}
|
|
181
|
+
try:
|
|
182
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
183
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
184
|
+
raise ValueError("invalid convergence state") from exc
|
|
185
|
+
if payload.get("version") not in {1, 2} or payload.get("feature") != feature or not isinstance(payload.get("fingerprints"), dict):
|
|
186
|
+
raise ValueError("invalid convergence state")
|
|
187
|
+
return {
|
|
188
|
+
"version": 2,
|
|
189
|
+
"feature": feature,
|
|
190
|
+
"fingerprints": {key: _normalize_entry(key, value) for key, value in payload["fingerprints"].items()},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _save(path: Path, payload: dict[str, Any]) -> None:
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
197
|
+
try:
|
|
198
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
199
|
+
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
|
|
200
|
+
handle.write("\n")
|
|
201
|
+
handle.flush()
|
|
202
|
+
os.fsync(handle.fileno())
|
|
203
|
+
os.replace(temporary, path)
|
|
204
|
+
finally:
|
|
205
|
+
try:
|
|
206
|
+
os.unlink(temporary)
|
|
207
|
+
except FileNotFoundError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _entry_key(state: dict[str, Any], requirement: str, root_cause: str, failure_path: str, previous_fingerprint: str | None) -> str:
|
|
212
|
+
key = previous_fingerprint or fingerprint(requirement, root_cause, failure_path)
|
|
213
|
+
current = state["fingerprints"].get(key)
|
|
214
|
+
if previous_fingerprint is not None and current is None:
|
|
215
|
+
raise ValueError("unknown previous fingerprint")
|
|
216
|
+
if current is not None and _normalize(str(current.get("requirement", ""))) != _normalize(requirement):
|
|
217
|
+
raise ValueError("previous fingerprint requirement mismatch")
|
|
218
|
+
if previous_fingerprint is None and any(
|
|
219
|
+
item.get("status") == "halted" and item.get("requirement") == _normalize(requirement)
|
|
220
|
+
for item in state["fingerprints"].values()
|
|
221
|
+
):
|
|
222
|
+
raise ValueError("halted fingerprint requires authorized resume")
|
|
223
|
+
return key
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def record_result(
|
|
227
|
+
root: Path, feature: str, requirement: str, root_cause: str, failure_path: str, *,
|
|
228
|
+
verifier_failed: bool, gate_passed: bool, previous_fingerprint: str | None = None,
|
|
229
|
+
independent: bool = False, evidence_ref: str | None = None,
|
|
230
|
+
failing_tests: list[Any] | tuple[Any, ...] = (),
|
|
231
|
+
fixes_tried: list[Any] | tuple[Any, ...] = (),
|
|
232
|
+
gate_available: bool = True,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
path = state_path(root, feature)
|
|
235
|
+
state = _load(path, feature)
|
|
236
|
+
key = _entry_key(state, requirement, root_cause, failure_path, previous_fingerprint)
|
|
237
|
+
current = state["fingerprints"].get(key)
|
|
238
|
+
if current is None:
|
|
239
|
+
current = {
|
|
240
|
+
"fingerprint": key, "requirement": _normalize(requirement),
|
|
241
|
+
"root_cause": _normalize(root_cause), "failure_path": _normalize(failure_path),
|
|
242
|
+
"failed_remediations": 0, "status": "open", "current_generation": 1,
|
|
243
|
+
"generations": [_generation(1, 0, "open")],
|
|
244
|
+
}
|
|
245
|
+
else:
|
|
246
|
+
current = deepcopy(current)
|
|
247
|
+
if previous_fingerprint is not None and current["status"] == "halted":
|
|
248
|
+
if _normalize(root_cause) != current["root_cause"] or _normalize(failure_path) != current["failure_path"]:
|
|
249
|
+
raise ValueError("halted fingerprint identity cannot be reworded")
|
|
250
|
+
elif previous_fingerprint is not None:
|
|
251
|
+
current.update({"requirement": _normalize(requirement), "root_cause": _normalize(root_cause), "failure_path": _normalize(failure_path)})
|
|
252
|
+
generation = current["generations"][-1]
|
|
253
|
+
prior_status = current["status"]
|
|
254
|
+
remediation_attempt = verifier_failed or bool(failing_tests) or bool(fixes_tried) or not gate_available
|
|
255
|
+
if remediation_attempt and prior_status == "halted":
|
|
256
|
+
raise ValueError("halted fingerprint requires authorized resume")
|
|
257
|
+
transition: dict[str, Any] | None = None
|
|
258
|
+
if remediation_attempt:
|
|
259
|
+
transition = remediation.transition_remediation(
|
|
260
|
+
generation,
|
|
261
|
+
failing_tests,
|
|
262
|
+
stall_attempts=workflow_config.stall_attempts(root),
|
|
263
|
+
gate_available=gate_available,
|
|
264
|
+
fixes_tried=fixes_tried,
|
|
265
|
+
)
|
|
266
|
+
for field in REMEDIATION_FIELDS:
|
|
267
|
+
generation[field] = transition[field]
|
|
268
|
+
if transition["halted"]:
|
|
269
|
+
generation["status"] = "halted"
|
|
270
|
+
generation["halt_reason"] = transition["reason"]
|
|
271
|
+
elif verifier_failed:
|
|
272
|
+
generation["status"] = "open"
|
|
273
|
+
generation.pop("halt_event", None)
|
|
274
|
+
generation.pop("halt_reason", None)
|
|
275
|
+
if verifier_failed:
|
|
276
|
+
generation["failed_remediations"] += 1
|
|
277
|
+
if transition is None or not transition["halted"]:
|
|
278
|
+
generation["status"] = "open"
|
|
279
|
+
generation.pop("halt_event", None)
|
|
280
|
+
generation.pop("halt_reason", None)
|
|
281
|
+
if transition is not None and transition["halted"]:
|
|
282
|
+
generation["halt_event"] = _halt_event(generation["generation"], generation["failed_remediations"])
|
|
283
|
+
elif gate_passed and previous_fingerprint is not None and current["status"] == "open":
|
|
284
|
+
qualifies = independent and bool(evidence_ref) and _valid_authorization(evidence_ref)
|
|
285
|
+
if current["current_generation"] == 1 or qualifies:
|
|
286
|
+
if current["current_generation"] > 1:
|
|
287
|
+
generation["evidence_ref"] = evidence_ref
|
|
288
|
+
generation["status"] = "closed"
|
|
289
|
+
generation.pop("halt_event", None)
|
|
290
|
+
current["failed_remediations"] = sum(item["failed_remediations"] for item in current["generations"])
|
|
291
|
+
current["status"] = current["generations"][-1]["status"]
|
|
292
|
+
state["fingerprints"][key] = current
|
|
293
|
+
_save(path, state)
|
|
294
|
+
return deepcopy(current)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def record_failure(
|
|
298
|
+
root: Path, feature: str, requirement: str, root_cause: str, failure_path: str, *,
|
|
299
|
+
gate_passed: bool = False, previous_fingerprint: str | None = None,
|
|
300
|
+
failing_tests: list[Any] | tuple[Any, ...] = (),
|
|
301
|
+
fixes_tried: list[Any] | tuple[Any, ...] = (),
|
|
302
|
+
gate_available: bool = True,
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
return record_result(
|
|
305
|
+
root, feature, requirement, root_cause, failure_path,
|
|
306
|
+
verifier_failed=True, gate_passed=gate_passed,
|
|
307
|
+
previous_fingerprint=previous_fingerprint, failing_tests=failing_tests,
|
|
308
|
+
fixes_tried=fixes_tried, gate_available=gate_available,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def resume(root: Path, feature: str, resume_fingerprint: str, authorization_ref: str) -> dict[str, Any]:
|
|
313
|
+
"""Append an authorized generation without mutating the halted history."""
|
|
314
|
+
path = state_path(root, feature)
|
|
315
|
+
state = _load(path, feature)
|
|
316
|
+
if not re.fullmatch(r"[0-9a-f]{64}", resume_fingerprint):
|
|
317
|
+
raise ValueError("invalid resume fingerprint")
|
|
318
|
+
if not _valid_authorization(authorization_ref):
|
|
319
|
+
raise ValueError("invalid authorization reference")
|
|
320
|
+
current = state["fingerprints"].get(resume_fingerprint)
|
|
321
|
+
if current is None:
|
|
322
|
+
raise ValueError("unknown resume fingerprint")
|
|
323
|
+
if current["status"] != "halted" or current["generations"][-1]["status"] != "halted":
|
|
324
|
+
raise ValueError("resume requires halted fingerprint")
|
|
325
|
+
updated = deepcopy(current)
|
|
326
|
+
generation = _generation(current["current_generation"] + 1, 0, "open", authorization_ref=authorization_ref)
|
|
327
|
+
updated["generations"].append(generation)
|
|
328
|
+
updated["current_generation"] = generation["generation"]
|
|
329
|
+
updated["status"] = "open"
|
|
330
|
+
updated["failed_remediations"] = sum(item["failed_remediations"] for item in updated["generations"])
|
|
331
|
+
state["fingerprints"][resume_fingerprint] = updated
|
|
332
|
+
_save(path, state)
|
|
333
|
+
return deepcopy(updated)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(argv: list[str] | None = None) -> int:
|
|
337
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
338
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
339
|
+
parser.add_argument("--feature", required=True)
|
|
340
|
+
parser.add_argument("--requirement")
|
|
341
|
+
parser.add_argument("--root-cause")
|
|
342
|
+
parser.add_argument("--failure-path")
|
|
343
|
+
parser.add_argument("--previous-fingerprint")
|
|
344
|
+
parser.add_argument("--resume-fingerprint")
|
|
345
|
+
parser.add_argument("--authorization-ref")
|
|
346
|
+
parser.add_argument("--verifier-failed", action="store_true")
|
|
347
|
+
parser.add_argument("--gate-passed", action="store_true")
|
|
348
|
+
parser.add_argument("--independent", action="store_true")
|
|
349
|
+
parser.add_argument("--evidence-ref")
|
|
350
|
+
parser.add_argument("--failing-test", action="append", default=[])
|
|
351
|
+
parser.add_argument("--fix-tried", action="append", default=[])
|
|
352
|
+
parser.add_argument("--gate-unavailable", action="store_true")
|
|
353
|
+
args = parser.parse_args(argv)
|
|
354
|
+
if args.resume_fingerprint is not None:
|
|
355
|
+
if args.authorization_ref is None:
|
|
356
|
+
raise SystemExit("--authorization-ref is required with --resume-fingerprint")
|
|
357
|
+
result = resume(args.root, args.feature, args.resume_fingerprint, args.authorization_ref)
|
|
358
|
+
else:
|
|
359
|
+
if any(value is None for value in (args.requirement, args.root_cause, args.failure_path)):
|
|
360
|
+
raise SystemExit("--requirement, --root-cause, and --failure-path are required")
|
|
361
|
+
result = record_result(
|
|
362
|
+
args.root, args.feature, args.requirement, args.root_cause, args.failure_path,
|
|
363
|
+
verifier_failed=args.verifier_failed, gate_passed=args.gate_passed,
|
|
364
|
+
previous_fingerprint=args.previous_fingerprint, independent=args.independent,
|
|
365
|
+
evidence_ref=args.evidence_ref, failing_tests=args.failing_test,
|
|
366
|
+
fixes_tried=args.fix_tried, gate_available=not args.gate_unavailable,
|
|
367
|
+
)
|
|
368
|
+
print(json.dumps(result, sort_keys=True))
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
raise SystemExit(main())
|