program-context-protocol 0.12.4__py3-none-any.whl
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.
- pcp/__init__.py +3 -0
- pcp/assertions.py +152 -0
- pcp/attest.py +111 -0
- pcp/build_loop_bypass.py +76 -0
- pcp/build_report.py +54 -0
- pcp/capture.py +339 -0
- pcp/cli.py +104 -0
- pcp/commands/__init__.py +0 -0
- pcp/commands/amend.py +283 -0
- pcp/commands/architect_review.py +291 -0
- pcp/commands/architecture_justification.py +164 -0
- pcp/commands/audit.py +371 -0
- pcp/commands/build.py +4523 -0
- pcp/commands/build_plan.py +153 -0
- pcp/commands/build_status.py +83 -0
- pcp/commands/capture.py +72 -0
- pcp/commands/check.py +584 -0
- pcp/commands/context.py +151 -0
- pcp/commands/control_audit_cmd.py +54 -0
- pcp/commands/correct_objective.py +160 -0
- pcp/commands/dashboard.py +732 -0
- pcp/commands/deploy.py +199 -0
- pcp/commands/deploy_check.py +134 -0
- pcp/commands/design_audit.py +323 -0
- pcp/commands/diff.py +153 -0
- pcp/commands/diff_reduce.py +355 -0
- pcp/commands/docs.py +538 -0
- pcp/commands/doctor.py +820 -0
- pcp/commands/escalations_cmd.py +64 -0
- pcp/commands/gate.py +209 -0
- pcp/commands/import_project.py +404 -0
- pcp/commands/init.py +1634 -0
- pcp/commands/install_hook.py +283 -0
- pcp/commands/install_skill.py +48 -0
- pcp/commands/kickoff.py +772 -0
- pcp/commands/narrative_lint.py +54 -0
- pcp/commands/objective_conflicts_cmd.py +68 -0
- pcp/commands/pm.py +504 -0
- pcp/commands/pressure_test_cmd.py +72 -0
- pcp/commands/provenance.py +313 -0
- pcp/commands/prune.py +179 -0
- pcp/commands/report.py +49 -0
- pcp/commands/run_log_cmd.py +122 -0
- pcp/commands/scan.py +346 -0
- pcp/commands/self_update.py +125 -0
- pcp/commands/status.py +180 -0
- pcp/commands/takeover.py +55 -0
- pcp/commands/telemetry_cmd.py +167 -0
- pcp/commands/validate_module.py +153 -0
- pcp/commands/validate_strategy.py +413 -0
- pcp/commands/verify.py +166 -0
- pcp/commands/verify_syntax_fix.py +74 -0
- pcp/commands/watch.py +372 -0
- pcp/config_audit.py +141 -0
- pcp/context_map.py +124 -0
- pcp/control_audit.py +159 -0
- pcp/coupling.py +178 -0
- pcp/coverage_audit.py +77 -0
- pcp/decision_log.py +134 -0
- pcp/discovery/__init__.py +0 -0
- pcp/discovery/clusters.py +124 -0
- pcp/discovery/graph.py +110 -0
- pcp/discovery/scanner.py +109 -0
- pcp/escalations.py +193 -0
- pcp/evidence.py +30 -0
- pcp/evidence_chain.py +56 -0
- pcp/impact.py +164 -0
- pcp/install_approvals.py +44 -0
- pcp/integrity_audit.py +176 -0
- pcp/librarian.py +89 -0
- pcp/llm/__init__.py +0 -0
- pcp/llm/client.py +183 -0
- pcp/llm/coding_agent_contract.py +104 -0
- pcp/llm/harness/__init__.py +12 -0
- pcp/llm/harness/agy.py +121 -0
- pcp/llm/harness/agy_coding_loop.py +180 -0
- pcp/llm/harness/claude.py +241 -0
- pcp/llm/ledger.py +47 -0
- pcp/narrative_lint.py +229 -0
- pcp/nav_graph.py +226 -0
- pcp/objective_conflicts.py +129 -0
- pcp/operational.py +70 -0
- pcp/orphaned_work.py +262 -0
- pcp/pcp_dir.py +35 -0
- pcp/pcp_status.py +313 -0
- pcp/policy.py +81 -0
- pcp/pressure_test.py +196 -0
- pcp/qa.py +445 -0
- pcp/run_log.py +225 -0
- pcp/schema/__init__.py +0 -0
- pcp/schema/ci_rules.schema.json +106 -0
- pcp/schema/controls.schema.json +39 -0
- pcp/schema/module_acceptance.schema.json +144 -0
- pcp/schema/module_spec.schema.json +78 -0
- pcp/schema/sdlc_phase.schema.json +52 -0
- pcp/schema/validator.py +77 -0
- pcp/skill_data/pcp/SKILL.md +1897 -0
- pcp/spec_write.py +269 -0
- pcp/spend.py +77 -0
- pcp/symbols.py +86 -0
- pcp/telemetry.py +308 -0
- pcp/uat.py +271 -0
- pcp/version_drift.py +222 -0
- program_context_protocol-0.12.4.dist-info/METADATA +123 -0
- program_context_protocol-0.12.4.dist-info/RECORD +109 -0
- program_context_protocol-0.12.4.dist-info/WHEEL +4 -0
- program_context_protocol-0.12.4.dist-info/entry_points.txt +2 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-APACHE +202 -0
- program_context_protocol-0.12.4.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""pcp build-plan — deterministic execution plan, no agents spawned.
|
|
2
|
+
|
|
3
|
+
Root of tonight's redesign (2026-07-30): `pcp build`'s Python execution engine
|
|
4
|
+
(worktree-per-criterion, ThreadPoolExecutor, merge/retry-on-conflict) is one of
|
|
5
|
+
TWO independent orchestration implementations in this codebase -- the `/pcp`
|
|
6
|
+
skill's own SKILL.md already documents a second one, built on the Workflow
|
|
7
|
+
tool's `pipeline()`/`parallel()`, and CLAUDE.md describes both as if they were
|
|
8
|
+
the same mechanism. They aren't. The Python engine reinvents coordination the
|
|
9
|
+
harness already provides natively, and pays for it: measured on Project O,
|
|
10
|
+
5 of 5 completed `query-eval-harness` criteria hit a merge conflict in one run,
|
|
11
|
+
99% of that run's cost sat on the conflicted criteria, and two criteria were
|
|
12
|
+
still stuck mid-retry two hours in.
|
|
13
|
+
|
|
14
|
+
The concrete root cause, read directly out of the colliding file: every
|
|
15
|
+
criterion in a module has to add its own method to one shared facade class in
|
|
16
|
+
`__init__.py` and remove its own entry from a shared `_PENDING` dict -- small,
|
|
17
|
+
non-overlapping in meaning, but landing in the same file at the same insertion
|
|
18
|
+
anchor, so git's diff sees a conflict where nothing semantically conflicts.
|
|
19
|
+
|
|
20
|
+
This command is the new split: Python stays the planner (deterministic, rung 1,
|
|
21
|
+
reuses the exact wave-computation this project already had -- nothing here is
|
|
22
|
+
new logic, it's `gather_modules_to_build`/`compute_waves`/
|
|
23
|
+
`_compute_criterion_waves` from build.py, called and serialized rather than
|
|
24
|
+
immediately acted on). Python stops being the executor. Emits a plan; spawns
|
|
25
|
+
nothing. Execution -- actually running agents against that plan -- happens
|
|
26
|
+
through the Workflow tool from an orchestrating session (the `/pcp` skill),
|
|
27
|
+
using real harness primitives: `parallel()` for criteria that provably touch
|
|
28
|
+
disjoint files, `pipeline()`/a single-writer step for the ones that don't.
|
|
29
|
+
|
|
30
|
+
**Shared-surface files are the reason a per-criterion `target` isn't enough to
|
|
31
|
+
schedule safely.** A criterion's own new file (`metrics/plan_correctness.py`)
|
|
32
|
+
is genuinely disjoint from another criterion's new file -- true parallelism is
|
|
33
|
+
safe there. But BOTH criteria still touch the module's `__init__.py` to
|
|
34
|
+
register, and no criterion ever declares that as its `target` (it's an
|
|
35
|
+
implicit side effect of the registration convention every `pcp init --module`
|
|
36
|
+
scaffold uses, not something anyone writes down). So `shared_surface_files`
|
|
37
|
+
here is not inferred from declared targets at all -- it's the module's own
|
|
38
|
+
known facade files: `src/modules/<module>/__init__.py` and any file a
|
|
39
|
+
MOD_A00x criterion in that module declares as `target` (MOD_A002's app-registry
|
|
40
|
+
target, MOD_A004's interface-file target). Every criterion in the module is
|
|
41
|
+
marked as touching these implicitly, conservatively, whether or not its own
|
|
42
|
+
declared target says so -- the same asymmetry `_partition_wave_by_file_scope`
|
|
43
|
+
already reasons from: reopening/serializing too much costs parallelism,
|
|
44
|
+
missing a real collision costs a broken merge, and the second is worse.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
import click
|
|
52
|
+
from rich.console import Console
|
|
53
|
+
|
|
54
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
55
|
+
from pcp.commands.build import gather_modules_to_build, compute_waves, _compute_criterion_waves
|
|
56
|
+
|
|
57
|
+
console = Console()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _module_shared_surface(pcp_dir: Path, module_name: str) -> list[str]:
|
|
61
|
+
"""The facade files every criterion in this module implicitly touches to
|
|
62
|
+
register, regardless of what each criterion's own `target` says.
|
|
63
|
+
|
|
64
|
+
`src/modules/<module>/__init__.py` is the scaffold's own convention
|
|
65
|
+
(see init.py's MODULE_ACCEPTANCE_TEMPLATE / MOD_A002-A004) -- it exists
|
|
66
|
+
whether or not it's on disk yet, because the first criterion to land
|
|
67
|
+
creates it. MOD_A002 (registers through the application interface) and
|
|
68
|
+
MOD_A004 (interface file) declare their OWN targets explicitly; those are
|
|
69
|
+
pulled in here too, since registering ANY new criterion touches both the
|
|
70
|
+
facade and, if the module has one, the interface file.
|
|
71
|
+
"""
|
|
72
|
+
mod_slug = module_name.replace("-", "_")
|
|
73
|
+
surface = {f"src/modules/{mod_slug}/__init__.py"}
|
|
74
|
+
acc_path = pcp_dir / "strategy" / "modules" / module_name / "acceptance.yaml"
|
|
75
|
+
if acc_path.exists():
|
|
76
|
+
from pcp.schema.validator import load_yaml
|
|
77
|
+
try:
|
|
78
|
+
data = load_yaml(acc_path) or {}
|
|
79
|
+
except Exception:
|
|
80
|
+
data = {}
|
|
81
|
+
for c in data.get("criteria", []) or []:
|
|
82
|
+
cid = str(c.get("id") or "")
|
|
83
|
+
if cid.startswith("MOD_") and c.get("target"):
|
|
84
|
+
surface.add(c["target"])
|
|
85
|
+
return sorted(surface)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_plan(pcp_dir: Path, module_name: str | None = None) -> dict:
|
|
89
|
+
"""{modules: [{name, wave, shared_surface_files, dependencies,
|
|
90
|
+
criterion_waves: [[{id, description, check, target, depends_on,
|
|
91
|
+
touches_shared_surface}, ...], ...]}], total_criteria: int}.
|
|
92
|
+
|
|
93
|
+
Pure aggregation over data build.py already computes -- no new scheduling
|
|
94
|
+
logic, no LLM, nothing spawned. `criterion_waves` is a list of lists: each
|
|
95
|
+
inner list is one dependency wave (from `_compute_criterion_waves`), so
|
|
96
|
+
the consumer knows both "these can run together" and "in what order
|
|
97
|
+
relative to each other" without recomputing anything.
|
|
98
|
+
"""
|
|
99
|
+
modules = gather_modules_to_build(pcp_dir, module_name)
|
|
100
|
+
module_waves = compute_waves(modules)
|
|
101
|
+
|
|
102
|
+
out_modules = []
|
|
103
|
+
for mod in modules:
|
|
104
|
+
surface = _module_shared_surface(pcp_dir, mod["name"])
|
|
105
|
+
crit_wave_of = _compute_criterion_waves(mod)
|
|
106
|
+
max_wave = max(crit_wave_of.values()) if crit_wave_of else 0
|
|
107
|
+
|
|
108
|
+
criterion_waves = []
|
|
109
|
+
for w in range(max_wave + 1):
|
|
110
|
+
wave_criteria = [c for c in mod["pending_criteria"] if crit_wave_of.get(c["id"], 0) == w]
|
|
111
|
+
criterion_waves.append([
|
|
112
|
+
{
|
|
113
|
+
"id": c["id"],
|
|
114
|
+
"description": c.get("description", ""),
|
|
115
|
+
"check": c.get("check", "manual"),
|
|
116
|
+
"target": c.get("target"),
|
|
117
|
+
"depends_on": c.get("depends_on") or [],
|
|
118
|
+
"touches_shared_surface": True, # see _module_shared_surface docstring
|
|
119
|
+
}
|
|
120
|
+
for c in wave_criteria
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
out_modules.append({
|
|
124
|
+
"name": mod["name"],
|
|
125
|
+
"wave": module_waves.get(mod["name"], 0),
|
|
126
|
+
"dependencies": mod["spec"].get("dependencies") or [],
|
|
127
|
+
"shared_surface_files": surface,
|
|
128
|
+
"criterion_waves": criterion_waves,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
out_modules.sort(key=lambda m: (m["wave"], m["name"]))
|
|
132
|
+
total = sum(len(w) for m in out_modules for w in m["criterion_waves"])
|
|
133
|
+
return {"modules": out_modules, "total_criteria": total}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@click.command(name="build-plan")
|
|
137
|
+
@click.option("--module", "module_name", default=None, help="Limit the plan to one module.")
|
|
138
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
139
|
+
help="Project root override.")
|
|
140
|
+
def build_plan_cmd(module_name: str | None, project_path: str | None):
|
|
141
|
+
"""Emit the deterministic build plan as JSON. Spawns nothing.
|
|
142
|
+
|
|
143
|
+
Consumed by the `/pcp` skill's Workflow-tool execution -- see this
|
|
144
|
+
module's own docstring for why Python stopped being the executor.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
148
|
+
except NoPCPDir as e:
|
|
149
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
150
|
+
sys.exit(2)
|
|
151
|
+
|
|
152
|
+
plan = build_plan(pcp_dir, module_name)
|
|
153
|
+
click.echo(json.dumps(plan, indent=2))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""pcp build-status — live view of an in-progress `pcp build` run.
|
|
2
|
+
|
|
3
|
+
Reads `.pcp/build_progress.yaml`, written by build.py's `_write_progress()`
|
|
4
|
+
at each criterion-attempt checkpoint (coding / evaluating gates / done /
|
|
5
|
+
failed). Real gap this closes (2026-07-24, Project O incident):
|
|
6
|
+
`pcp build` gives no way to tell what it's currently doing short of reading
|
|
7
|
+
raw agent output -- backgrounded (`nohup pcp build ... &`) it's fully
|
|
8
|
+
opaque, which is what triggered "i want to see whats happening" and a build
|
|
9
|
+
getting killed over what looked like a stall.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import yaml
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
STALE_AFTER_SEC = 600 # a step with no update in 10 min is worth flagging
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_progress(pcp_dir: Path) -> dict | None:
|
|
29
|
+
path = pcp_dir / "build_progress.yaml"
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return yaml.safe_load(path.read_text()) or None
|
|
34
|
+
except yaml.YAMLError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_status(data: dict | None, now: datetime | None = None) -> str:
|
|
39
|
+
"""Pure formatting -- testable without a clock or filesystem."""
|
|
40
|
+
if not data:
|
|
41
|
+
return "No build in progress (no .pcp/build_progress.yaml yet)."
|
|
42
|
+
|
|
43
|
+
now = now or datetime.now(timezone.utc)
|
|
44
|
+
line = (
|
|
45
|
+
f"{data.get('module', '?')}/{data.get('criterion_id', '?')} "
|
|
46
|
+
f"attempt {data.get('attempt', '?')} — {data.get('step', '?')}"
|
|
47
|
+
)
|
|
48
|
+
updated_at = data.get("updated_at")
|
|
49
|
+
if updated_at:
|
|
50
|
+
try:
|
|
51
|
+
ts = datetime.strptime(updated_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
52
|
+
age_sec = (now - ts).total_seconds()
|
|
53
|
+
line += f" (updated {int(age_sec)}s ago)"
|
|
54
|
+
if age_sec > STALE_AFTER_SEC and data.get("step") not in ("done", "failed"):
|
|
55
|
+
line += " ⚠ no update in a while — may be stuck"
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
return line
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@click.command(name="build-status")
|
|
62
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
63
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
64
|
+
@click.option("--watch", is_flag=True, help="Poll and redraw every --interval seconds until Ctrl-C.")
|
|
65
|
+
@click.option("--interval", type=int, default=5, help="Poll interval in seconds for --watch (default 5).")
|
|
66
|
+
def build_status(project_path: str | None, watch: bool, interval: int):
|
|
67
|
+
"""Show what an in-progress `pcp build` run is currently doing."""
|
|
68
|
+
try:
|
|
69
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
70
|
+
except NoPCPDir as e:
|
|
71
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
72
|
+
sys.exit(2)
|
|
73
|
+
|
|
74
|
+
if not watch:
|
|
75
|
+
console.print(format_status(load_progress(pcp_dir)))
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
console.print(format_status(load_progress(pcp_dir)))
|
|
81
|
+
time.sleep(interval)
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
sys.exit(0)
|
pcp/commands/capture.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""pcp capture — classify a session transcript into business-logic drift
|
|
2
|
+
(-> .pcp/brd.md) and technical input (-> .pcp/decision_log.jsonl).
|
|
3
|
+
|
|
4
|
+
Advisory, never blocks. Designed to be wired to a Claude Code SessionEnd hook
|
|
5
|
+
(reads the hook's JSON payload from stdin) for human/PM/UAT sessions; `pcp
|
|
6
|
+
build` calls the same underlying pcp.capture.run_capture() directly for its
|
|
7
|
+
own per-criterion agent sessions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from pcp.pcp_dir import find_pcp_dir, NoPCPDir
|
|
18
|
+
from pcp.capture import run_capture
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.command()
|
|
24
|
+
@click.option("--path", "project_path", type=click.Path(), default=None,
|
|
25
|
+
help="Project root (default: cwd, walks up to find .pcp/).")
|
|
26
|
+
@click.option("--transcript-file", type=click.Path(exists=True), default=None,
|
|
27
|
+
help="Transcript JSONL to classify (manual/testing use). "
|
|
28
|
+
"Default: read a Claude Code SessionEnd hook payload from stdin.")
|
|
29
|
+
def capture(project_path: str | None, transcript_file: str | None):
|
|
30
|
+
"""Classify a session transcript into business/technical drift. Advisory, never blocks."""
|
|
31
|
+
try:
|
|
32
|
+
pcp_dir = find_pcp_dir(Path(project_path) if project_path else None)
|
|
33
|
+
except NoPCPDir:
|
|
34
|
+
# Silent — this may run via a hook in any project, not just PCP-managed ones.
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
transcript_path = None
|
|
38
|
+
session_id = None
|
|
39
|
+
|
|
40
|
+
if transcript_file:
|
|
41
|
+
transcript_path = Path(transcript_file)
|
|
42
|
+
# Claude Code transcripts are always named <session-id>.jsonl (same
|
|
43
|
+
# convention pcp.capture.find_transcript_for_session relies on) — derive
|
|
44
|
+
# it from the filename so manual/testing runs still get real traceability
|
|
45
|
+
# instead of session_id=None / source="session:unknown".
|
|
46
|
+
session_id = transcript_path.stem
|
|
47
|
+
else:
|
|
48
|
+
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
49
|
+
if raw.strip():
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(raw)
|
|
52
|
+
session_id = payload.get("session_id")
|
|
53
|
+
tp = payload.get("transcript_path")
|
|
54
|
+
if tp:
|
|
55
|
+
transcript_path = Path(tp)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
if not transcript_path or not transcript_path.exists():
|
|
60
|
+
console.print("[dim]pcp capture: no transcript available, skipping.[/dim]")
|
|
61
|
+
sys.exit(0)
|
|
62
|
+
|
|
63
|
+
result = run_capture(pcp_dir, transcript_path, source=f"session:{session_id or 'unknown'}", session_id=session_id)
|
|
64
|
+
|
|
65
|
+
if result.get("skipped"):
|
|
66
|
+
console.print(f"[dim]pcp capture: {result['skipped']}[/dim]")
|
|
67
|
+
else:
|
|
68
|
+
console.print(
|
|
69
|
+
f"[green]pcp capture:[/green] {result['business_count']} business item(s) -> .pcp/brd.md, "
|
|
70
|
+
f"{result['technical_count']} technical item(s) -> .pcp/decision_log.jsonl"
|
|
71
|
+
)
|
|
72
|
+
sys.exit(0)
|