@deftai/directive-content 0.55.2 → 0.56.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/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""capacity_show.py -- offline capacity-allocation accounting (#1419 Slice 4).
|
|
3
|
+
|
|
4
|
+
Surfaced via ``task capacity:show``. Derives per-bucket tallies directly from
|
|
5
|
+
the vBRIEF lifecycle folders (``vbrief/{proposed,pending,active,completed,
|
|
6
|
+
cancelled}/``) -- filesystem-truth, fully offline, no ``gh`` / network calls.
|
|
7
|
+
|
|
8
|
+
What it reports
|
|
9
|
+
---------------
|
|
10
|
+
For every protected bucket declared in
|
|
11
|
+
``plan.policy.capacityAllocation.buckets`` (see ``scripts/policy.py``):
|
|
12
|
+
|
|
13
|
+
* **Forward view** -- the in-flight mix: summed kind-aware weight of
|
|
14
|
+
``pending/`` + ``active/`` vBRIEFs in the bucket.
|
|
15
|
+
* **Backward view** -- the trailing-window mix: summed weight of ``completed/``
|
|
16
|
+
vBRIEFs whose ``plan.metadata.completedAt`` falls inside the configured
|
|
17
|
+
``window`` (days). The backward view drives the target-vs-actual *deficit*
|
|
18
|
+
column (acceptance a4).
|
|
19
|
+
* **Outcome (rework) overlay** -- weight of completed-in-window vBRIEFs flagged
|
|
20
|
+
as rework (``plan.metadata.outcome == "rework"`` or
|
|
21
|
+
``plan.metadata.rework == true``).
|
|
22
|
+
* **Cost overlay** -- summed grounded cost actuals (``plan.metadata.cost``) of
|
|
23
|
+
completed-in-window vBRIEFs; rendered ``none/estimate-only`` when no grounded
|
|
24
|
+
actuals exist.
|
|
25
|
+
|
|
26
|
+
Kind-aware counting (acceptance a2)
|
|
27
|
+
-----------------------------------
|
|
28
|
+
* ``kind == "story"`` (or unset) counts its own weight (1).
|
|
29
|
+
* An ``epic`` / ``phase`` that HAS children on disk (a ``plan.references[]``
|
|
30
|
+
entry of ``type == "x-vbrief/plan"``) counts 0 -- its children are counted
|
|
31
|
+
directly.
|
|
32
|
+
* An UNDECOMPOSED ``epic`` / ``phase`` (no child references) counts its
|
|
33
|
+
``plan.metadata.estimatedChildren`` (or the policy ``defaultEpicEstimate``,
|
|
34
|
+
framework default 3).
|
|
35
|
+
|
|
36
|
+
Advisory guards
|
|
37
|
+
---------------
|
|
38
|
+
* **minSampleSize (acceptance a1)** -- when the number of classified
|
|
39
|
+
completions in the window is below ``minSampleSize``, the engine reports
|
|
40
|
+
advisory mode and defers to ordering (the deficit numbers are still printed
|
|
41
|
+
but flagged as not yet load-bearing).
|
|
42
|
+
* **unit:cost guarded fallback (acceptance a5 / OQ2)** -- ``unit == "cost"`` is
|
|
43
|
+
selectable but self-guards: when grounded cost actuals are insufficient
|
|
44
|
+
(coverage below :data:`COST_COVERAGE_FLOOR`), the engine falls back to the
|
|
45
|
+
advisory ``vbrief-count`` unit and warns; the cost overlay renders
|
|
46
|
+
``none/estimate-only``. The cost-sync telemetry itself is out of scope /
|
|
47
|
+
upstream-blocked.
|
|
48
|
+
|
|
49
|
+
This surface is ADVISORY: it always exits 0. The companion three-state gate
|
|
50
|
+
``scripts/verify_capacity.py`` (``task verify:capacity``) is where an opt-in
|
|
51
|
+
``enforce`` posture can surface a non-zero exit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import sys
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from datetime import UTC, datetime
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
# Make sibling helpers importable both as __main__ and when imported by tests.
|
|
63
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
64
|
+
|
|
65
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
66
|
+
from policy import ( # noqa: E402, I001
|
|
67
|
+
CAPACITY_UNIT_COST,
|
|
68
|
+
DEFAULT_CAPACITY_UNIT,
|
|
69
|
+
DEFAULT_PENDING_DECISIONS_THRESHOLD,
|
|
70
|
+
AutonomyRecommendation,
|
|
71
|
+
CapacityAllocation,
|
|
72
|
+
pending_decisions_nudge_line,
|
|
73
|
+
recommend_autonomy_level,
|
|
74
|
+
resolve_autonomy,
|
|
75
|
+
resolve_capacity_allocation,
|
|
76
|
+
summarize_decision_backlog,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
reconfigure_stdio()
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Constants
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
#: Lifecycle folders that contribute to the FORWARD (in-flight) view.
|
|
86
|
+
FORWARD_FOLDERS: tuple[str, ...] = ("pending", "active")
|
|
87
|
+
|
|
88
|
+
#: Lifecycle folder that contributes to the BACKWARD (trailing-window) view.
|
|
89
|
+
BACKWARD_FOLDER: str = "completed"
|
|
90
|
+
|
|
91
|
+
#: Bucket label used when a vBRIEF carries no explicit ``capacityBucket`` and
|
|
92
|
+
#: the policy declares no ``defaultBucket``.
|
|
93
|
+
UNASSIGNED_BUCKET: str = "unassigned"
|
|
94
|
+
|
|
95
|
+
#: Minimum fraction of classified completions that must carry a positive cost
|
|
96
|
+
#: actual before ``unit:cost`` is treated as grounded. Below this the engine
|
|
97
|
+
#: falls back to advisory ``vbrief-count`` (acceptance a5 / OQ2).
|
|
98
|
+
COST_COVERAGE_FLOOR: float = 0.5
|
|
99
|
+
|
|
100
|
+
#: Kinds treated as parents whose undecomposed estimate is counted (a2).
|
|
101
|
+
_PARENT_KINDS: frozenset[str] = frozenset({"epic", "phase"})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Data model
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class VbriefRecord:
|
|
111
|
+
"""One vBRIEF's capacity-relevant facts, derived from disk."""
|
|
112
|
+
|
|
113
|
+
bucket: str
|
|
114
|
+
kind: str
|
|
115
|
+
weight: float
|
|
116
|
+
folder: str
|
|
117
|
+
classified: bool # carried an explicit capacityBucket
|
|
118
|
+
in_window: bool # completed within the trailing window (backward only)
|
|
119
|
+
completed_at_present: bool # a non-empty completedAt string is on disk
|
|
120
|
+
is_rework: bool
|
|
121
|
+
cost: float | None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class BucketTally:
|
|
126
|
+
"""Per-bucket forward/backward/rework/cost rollup."""
|
|
127
|
+
|
|
128
|
+
bucket_id: str
|
|
129
|
+
target: float
|
|
130
|
+
forward_weight: float = 0.0
|
|
131
|
+
backward_weight: float = 0.0
|
|
132
|
+
rework_weight: float = 0.0
|
|
133
|
+
cost_actual: float | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class CapacityReport:
|
|
138
|
+
"""Computed capacity report -- the testable core of ``capacity:show``."""
|
|
139
|
+
|
|
140
|
+
configured: bool
|
|
141
|
+
source: str
|
|
142
|
+
unit_requested: str
|
|
143
|
+
unit_effective: str
|
|
144
|
+
cost_fallback: bool
|
|
145
|
+
cost_fallback_reason: str | None
|
|
146
|
+
window_days: int
|
|
147
|
+
min_sample_size: int
|
|
148
|
+
classified_completions: int
|
|
149
|
+
unclassified_completions: int
|
|
150
|
+
advisory_mode: bool
|
|
151
|
+
advisory_reasons: list[str] = field(default_factory=list)
|
|
152
|
+
buckets: list[BucketTally] = field(default_factory=list)
|
|
153
|
+
total_forward: float = 0.0
|
|
154
|
+
total_backward: float = 0.0
|
|
155
|
+
policy_error: str | None = None
|
|
156
|
+
# Pending human-clearance backlog + earned-autonomy dial (#1419 Slice 5).
|
|
157
|
+
pending_decisions: int = 0
|
|
158
|
+
pending_decisions_threshold: int = DEFAULT_PENDING_DECISIONS_THRESHOLD
|
|
159
|
+
pending_by_kind: dict[str, int] = field(default_factory=dict)
|
|
160
|
+
pending_nudge: str = ""
|
|
161
|
+
autonomy_enabled: bool = True
|
|
162
|
+
autonomy: AutonomyRecommendation | None = None
|
|
163
|
+
|
|
164
|
+
def bucket_deficit(self, tally: BucketTally) -> float:
|
|
165
|
+
"""Backward target-vs-actual deficit (positive == under target).
|
|
166
|
+
|
|
167
|
+
``deficit = target_fraction * total_backward - backward_weight``. A
|
|
168
|
+
positive value means the bucket received LESS than its target share of
|
|
169
|
+
the trailing window's completed work -- i.e. it is being starved.
|
|
170
|
+
"""
|
|
171
|
+
target_weight = tally.target * self.total_backward
|
|
172
|
+
return round(target_weight - tally.backward_weight, 4)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Derivation helpers
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_iso(value: object) -> datetime | None:
|
|
181
|
+
"""Parse an ISO-8601 ``...Z`` timestamp to an aware datetime, or None."""
|
|
182
|
+
if not isinstance(value, str) or not value:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
if parsed.tzinfo is None:
|
|
189
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
190
|
+
return parsed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _plan_has_children(plan: dict) -> bool:
|
|
194
|
+
"""True when the plan declares an ``x-vbrief/plan`` child reference."""
|
|
195
|
+
refs = plan.get("references")
|
|
196
|
+
if not isinstance(refs, list):
|
|
197
|
+
return False
|
|
198
|
+
return any(
|
|
199
|
+
isinstance(ref, dict) and ref.get("type") == "x-vbrief/plan"
|
|
200
|
+
for ref in refs
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _coerce_cost(value: object) -> float | None:
|
|
205
|
+
"""Return a positive numeric cost actual, or None when absent/invalid."""
|
|
206
|
+
if isinstance(value, bool):
|
|
207
|
+
return None
|
|
208
|
+
if isinstance(value, int | float) and value > 0:
|
|
209
|
+
return float(value)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def classify_record(
|
|
214
|
+
plan: dict,
|
|
215
|
+
folder: str,
|
|
216
|
+
allocation: CapacityAllocation,
|
|
217
|
+
now: datetime,
|
|
218
|
+
) -> VbriefRecord:
|
|
219
|
+
"""Derive a :class:`VbriefRecord` from a single vBRIEF ``plan`` block."""
|
|
220
|
+
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
|
|
221
|
+
kind_raw = metadata.get("kind")
|
|
222
|
+
kind = kind_raw if isinstance(kind_raw, str) and kind_raw else "story"
|
|
223
|
+
|
|
224
|
+
explicit_bucket = metadata.get("capacityBucket")
|
|
225
|
+
classified = isinstance(explicit_bucket, str) and bool(explicit_bucket.strip())
|
|
226
|
+
if classified:
|
|
227
|
+
bucket = explicit_bucket.strip()
|
|
228
|
+
elif allocation.default_bucket:
|
|
229
|
+
bucket = allocation.default_bucket
|
|
230
|
+
else:
|
|
231
|
+
bucket = UNASSIGNED_BUCKET
|
|
232
|
+
|
|
233
|
+
weight = _record_weight(kind, plan, metadata, allocation)
|
|
234
|
+
|
|
235
|
+
in_window = False
|
|
236
|
+
# Mirror capacity_backfill's ``has_completed_at`` semantics: a non-empty
|
|
237
|
+
# string counts as present even if it does not parse (backfill preserves it
|
|
238
|
+
# and never re-stamps, so such an item can never enter the window).
|
|
239
|
+
raw_completed_at = metadata.get("completedAt")
|
|
240
|
+
completed_at_present = isinstance(raw_completed_at, str) and bool(
|
|
241
|
+
raw_completed_at.strip()
|
|
242
|
+
)
|
|
243
|
+
if folder == BACKWARD_FOLDER:
|
|
244
|
+
completed_at = _parse_iso(raw_completed_at)
|
|
245
|
+
if completed_at is not None:
|
|
246
|
+
age_days = (now - completed_at).total_seconds() / 86400.0
|
|
247
|
+
in_window = 0 <= age_days <= allocation.window_days
|
|
248
|
+
|
|
249
|
+
outcome = metadata.get("outcome")
|
|
250
|
+
is_rework = (isinstance(outcome, str) and outcome.lower() == "rework") or (
|
|
251
|
+
metadata.get("rework") is True
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return VbriefRecord(
|
|
255
|
+
bucket=bucket,
|
|
256
|
+
kind=kind,
|
|
257
|
+
weight=weight,
|
|
258
|
+
folder=folder,
|
|
259
|
+
classified=classified,
|
|
260
|
+
in_window=in_window,
|
|
261
|
+
completed_at_present=completed_at_present,
|
|
262
|
+
is_rework=is_rework,
|
|
263
|
+
cost=_coerce_cost(metadata.get("cost")),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _record_weight(
|
|
268
|
+
kind: str, plan: dict, metadata: dict, allocation: CapacityAllocation
|
|
269
|
+
) -> float:
|
|
270
|
+
"""Kind-aware weight for one vBRIEF (acceptance a2)."""
|
|
271
|
+
if kind in _PARENT_KINDS:
|
|
272
|
+
if _plan_has_children(plan):
|
|
273
|
+
# Decomposed parent -- children are counted directly.
|
|
274
|
+
return 0.0
|
|
275
|
+
# Undecomposed epic/phase -- count its estimated children.
|
|
276
|
+
estimated = metadata.get("estimatedChildren")
|
|
277
|
+
if isinstance(estimated, int) and not isinstance(estimated, bool) and estimated > 0:
|
|
278
|
+
return float(estimated)
|
|
279
|
+
return float(allocation.default_epic_estimate)
|
|
280
|
+
# Stories (and any unknown kind) count their own single weight.
|
|
281
|
+
return 1.0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def iter_vbrief_plans(vbrief_root: Path) -> list[tuple[str, dict]]:
|
|
285
|
+
"""Yield ``(folder, plan)`` for every readable vBRIEF in the lifecycle dirs.
|
|
286
|
+
|
|
287
|
+
Unreadable / malformed files are skipped (offline accounting is
|
|
288
|
+
best-effort over filesystem-truth, not a validator).
|
|
289
|
+
"""
|
|
290
|
+
import json
|
|
291
|
+
|
|
292
|
+
out: list[tuple[str, dict]] = []
|
|
293
|
+
for folder in (*FORWARD_FOLDERS, BACKWARD_FOLDER):
|
|
294
|
+
folder_path = vbrief_root / folder
|
|
295
|
+
if not folder_path.is_dir():
|
|
296
|
+
continue
|
|
297
|
+
for child in sorted(folder_path.iterdir()):
|
|
298
|
+
if not (child.is_file() and child.name.endswith(".vbrief.json")):
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
data = json.loads(child.read_text(encoding="utf-8"))
|
|
302
|
+
except (OSError, ValueError):
|
|
303
|
+
continue
|
|
304
|
+
plan = data.get("plan") if isinstance(data, dict) else None
|
|
305
|
+
if isinstance(plan, dict):
|
|
306
|
+
out.append((folder, plan))
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Report computation
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def compute_report(
|
|
316
|
+
project_root: Path,
|
|
317
|
+
*,
|
|
318
|
+
now: datetime | None = None,
|
|
319
|
+
allocation: CapacityAllocation | None = None,
|
|
320
|
+
) -> CapacityReport:
|
|
321
|
+
"""Compute the :class:`CapacityReport` for *project_root* (offline)."""
|
|
322
|
+
now = now or datetime.now(UTC)
|
|
323
|
+
allocation = allocation or resolve_capacity_allocation(project_root)
|
|
324
|
+
vbrief_root = project_root / "vbrief"
|
|
325
|
+
|
|
326
|
+
records = [
|
|
327
|
+
classify_record(plan, folder, allocation, now)
|
|
328
|
+
for folder, plan in iter_vbrief_plans(vbrief_root)
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
# Seed tallies from the configured buckets (preserving declaration order),
|
|
332
|
+
# then add any extra buckets discovered on disk (sorted) so unassigned /
|
|
333
|
+
# mis-tagged work is still visible.
|
|
334
|
+
tallies: dict[str, BucketTally] = {}
|
|
335
|
+
for bucket in allocation.buckets:
|
|
336
|
+
tallies[bucket.bucket_id] = BucketTally(
|
|
337
|
+
bucket_id=bucket.bucket_id, target=bucket.target
|
|
338
|
+
)
|
|
339
|
+
for record in records:
|
|
340
|
+
if record.bucket not in tallies:
|
|
341
|
+
tallies[record.bucket] = BucketTally(bucket_id=record.bucket, target=0.0)
|
|
342
|
+
|
|
343
|
+
classified_completions = 0
|
|
344
|
+
# Completed vBRIEFs that carry no explicit capacityBucket AND that a backfill
|
|
345
|
+
# could actually pull into the window-scoped classified set (#1606). An
|
|
346
|
+
# unclassified completion with an explicit completedAt OUTSIDE the trailing
|
|
347
|
+
# window is EXCLUDED: backfill would stamp its bucket but leave completedAt
|
|
348
|
+
# out of window, so classified_completions never rises and advisory mode
|
|
349
|
+
# would persist silently -- promising such a migration is misleading. An
|
|
350
|
+
# absent completedAt is included: backfill stamps the git landing time,
|
|
351
|
+
# which may land in window. A positive count while sample-short means
|
|
352
|
+
# `task capacity:backfill` can classify history and cross minSampleSize.
|
|
353
|
+
unclassified_completions = sum(
|
|
354
|
+
1
|
|
355
|
+
for record in records
|
|
356
|
+
if record.folder == BACKWARD_FOLDER
|
|
357
|
+
and not record.classified
|
|
358
|
+
and (record.in_window or not record.completed_at_present)
|
|
359
|
+
)
|
|
360
|
+
cost_eligible = 0 # classified completions in window
|
|
361
|
+
cost_with_actual = 0
|
|
362
|
+
for record in records:
|
|
363
|
+
tally = tallies[record.bucket]
|
|
364
|
+
if record.folder in FORWARD_FOLDERS:
|
|
365
|
+
tally.forward_weight += record.weight
|
|
366
|
+
elif record.folder == BACKWARD_FOLDER and record.in_window:
|
|
367
|
+
tally.backward_weight += record.weight
|
|
368
|
+
if record.is_rework:
|
|
369
|
+
tally.rework_weight += record.weight
|
|
370
|
+
if record.classified:
|
|
371
|
+
classified_completions += 1
|
|
372
|
+
cost_eligible += 1
|
|
373
|
+
if record.cost is not None:
|
|
374
|
+
cost_with_actual += 1
|
|
375
|
+
tally.cost_actual = (tally.cost_actual or 0.0) + record.cost
|
|
376
|
+
|
|
377
|
+
total_forward = sum(t.forward_weight for t in tallies.values())
|
|
378
|
+
total_backward = sum(t.backward_weight for t in tallies.values())
|
|
379
|
+
total_rework = sum(t.rework_weight for t in tallies.values())
|
|
380
|
+
|
|
381
|
+
# Bucket ordering: configured buckets first (declaration order), then
|
|
382
|
+
# discovered extras alphabetically for deterministic output.
|
|
383
|
+
configured_ids = [b.bucket_id for b in allocation.buckets]
|
|
384
|
+
extras = sorted(bid for bid in tallies if bid not in configured_ids)
|
|
385
|
+
ordered = [tallies[bid] for bid in (*configured_ids, *extras)]
|
|
386
|
+
|
|
387
|
+
unit_requested = allocation.unit if allocation.unit in {
|
|
388
|
+
DEFAULT_CAPACITY_UNIT,
|
|
389
|
+
CAPACITY_UNIT_COST,
|
|
390
|
+
} else DEFAULT_CAPACITY_UNIT
|
|
391
|
+
unit_effective, cost_fallback, cost_reason = _resolve_effective_unit(
|
|
392
|
+
unit_requested, cost_eligible, cost_with_actual
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
advisory_reasons: list[str] = []
|
|
396
|
+
if not allocation.configured:
|
|
397
|
+
advisory_reasons.append(
|
|
398
|
+
"capacityAllocation not configured -- showing discovered buckets only"
|
|
399
|
+
)
|
|
400
|
+
sample_short = classified_completions < allocation.min_sample_size
|
|
401
|
+
if sample_short:
|
|
402
|
+
advisory_reasons.append(
|
|
403
|
+
f"only {classified_completions} classified completion(s) in window "
|
|
404
|
+
f"(< minSampleSize={allocation.min_sample_size}) -- deferring to ordering"
|
|
405
|
+
)
|
|
406
|
+
# Actionable discoverability hint (#1606): when buckets ARE configured
|
|
407
|
+
# but completed history is unclassified, point the operator at the
|
|
408
|
+
# one-time backfill that crosses minSampleSize.
|
|
409
|
+
if allocation.configured and unclassified_completions > 0:
|
|
410
|
+
advisory_reasons.append(
|
|
411
|
+
f"{unclassified_completions} completed vBRIEF(s) are unclassified "
|
|
412
|
+
"-- run `task capacity:backfill --apply` (one-time) to classify "
|
|
413
|
+
"history and activate capacity accounting"
|
|
414
|
+
)
|
|
415
|
+
if cost_fallback and cost_reason:
|
|
416
|
+
advisory_reasons.append(cost_reason)
|
|
417
|
+
|
|
418
|
+
# Pending human-clearance backlog + earned-autonomy dial (#1419 Slice 5).
|
|
419
|
+
# The backlog count is derived from the durable audit log; the autonomy
|
|
420
|
+
# dial is computed from the override rate (primary) + rework rate
|
|
421
|
+
# (guardrail) over the SAME trailing window and is ADVISORY-ONLY -- the
|
|
422
|
+
# recommendation is surfaced, never auto-applied.
|
|
423
|
+
backlog = summarize_decision_backlog(
|
|
424
|
+
project_root, now=now, window_days=allocation.window_days
|
|
425
|
+
)
|
|
426
|
+
rework_rate = total_rework / total_backward if total_backward > 0 else 0.0
|
|
427
|
+
autonomy_policy = resolve_autonomy(project_root)
|
|
428
|
+
# Honour the enabled flag: a project that sets autonomy.enabled=false gets
|
|
429
|
+
# no dial recommendation at all (autonomy stays None), so the render guard
|
|
430
|
+
# below suppresses the line. Default policy is enabled.
|
|
431
|
+
autonomy = (
|
|
432
|
+
recommend_autonomy_level(
|
|
433
|
+
autonomy_policy.default_level,
|
|
434
|
+
override_rate=backlog.override_rate,
|
|
435
|
+
rework_rate=rework_rate,
|
|
436
|
+
sample_size=backlog.resolved_in_window,
|
|
437
|
+
p0_reversal=backlog.p0_reversal_in_window,
|
|
438
|
+
policy=autonomy_policy,
|
|
439
|
+
)
|
|
440
|
+
if autonomy_policy.enabled
|
|
441
|
+
else None
|
|
442
|
+
)
|
|
443
|
+
pending_nudge = pending_decisions_nudge_line(backlog.pending_count)
|
|
444
|
+
|
|
445
|
+
return CapacityReport(
|
|
446
|
+
configured=allocation.configured,
|
|
447
|
+
source=allocation.source,
|
|
448
|
+
unit_requested=unit_requested,
|
|
449
|
+
unit_effective=unit_effective,
|
|
450
|
+
cost_fallback=cost_fallback,
|
|
451
|
+
cost_fallback_reason=cost_reason,
|
|
452
|
+
window_days=allocation.window_days,
|
|
453
|
+
min_sample_size=allocation.min_sample_size,
|
|
454
|
+
classified_completions=classified_completions,
|
|
455
|
+
unclassified_completions=unclassified_completions,
|
|
456
|
+
advisory_mode=sample_short or not allocation.configured,
|
|
457
|
+
advisory_reasons=advisory_reasons,
|
|
458
|
+
buckets=ordered,
|
|
459
|
+
total_forward=total_forward,
|
|
460
|
+
total_backward=total_backward,
|
|
461
|
+
policy_error=allocation.error,
|
|
462
|
+
pending_decisions=backlog.pending_count,
|
|
463
|
+
pending_by_kind=dict(backlog.by_kind),
|
|
464
|
+
pending_nudge=pending_nudge,
|
|
465
|
+
autonomy_enabled=autonomy_policy.enabled,
|
|
466
|
+
autonomy=autonomy,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _resolve_effective_unit(
|
|
471
|
+
unit_requested: str, cost_eligible: int, cost_with_actual: int
|
|
472
|
+
) -> tuple[str, bool, str | None]:
|
|
473
|
+
"""Apply the unit:cost guarded fallback (acceptance a5 / OQ2).
|
|
474
|
+
|
|
475
|
+
Returns ``(unit_effective, cost_fallback, reason)``. ``cost`` falls back to
|
|
476
|
+
advisory ``vbrief-count`` when grounded actuals are insufficient (no
|
|
477
|
+
eligible completions, or coverage below :data:`COST_COVERAGE_FLOOR`).
|
|
478
|
+
"""
|
|
479
|
+
if unit_requested != CAPACITY_UNIT_COST:
|
|
480
|
+
return unit_requested, False, None
|
|
481
|
+
if cost_eligible == 0:
|
|
482
|
+
return (
|
|
483
|
+
DEFAULT_CAPACITY_UNIT,
|
|
484
|
+
True,
|
|
485
|
+
"unit:cost requested but no classified completions carry grounded "
|
|
486
|
+
"cost actuals -- falling back to advisory vbrief-count "
|
|
487
|
+
"(cost overlay: none/estimate-only)",
|
|
488
|
+
)
|
|
489
|
+
coverage = cost_with_actual / cost_eligible
|
|
490
|
+
if coverage < COST_COVERAGE_FLOOR:
|
|
491
|
+
return (
|
|
492
|
+
DEFAULT_CAPACITY_UNIT,
|
|
493
|
+
True,
|
|
494
|
+
f"unit:cost requested but only {cost_with_actual}/{cost_eligible} "
|
|
495
|
+
f"({coverage:.0%}) classified completions carry grounded cost "
|
|
496
|
+
f"actuals (< {COST_COVERAGE_FLOOR:.0%} floor) -- falling back to "
|
|
497
|
+
"advisory vbrief-count (cost overlay: none/estimate-only)",
|
|
498
|
+
)
|
|
499
|
+
return CAPACITY_UNIT_COST, False, None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# Rendering
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _format_cost(value: float | None) -> str:
|
|
508
|
+
"""Render the cost-overlay cell."""
|
|
509
|
+
if value is None:
|
|
510
|
+
return "none/estimate-only"
|
|
511
|
+
return f"{value:.2f}"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _append_backlog_and_autonomy(lines: list[str], report: CapacityReport) -> None:
|
|
515
|
+
"""Append the pending-decisions backlog + advisory autonomy block (#1419 S5)."""
|
|
516
|
+
lines.append(
|
|
517
|
+
f" Pending human decisions: {report.pending_decisions} "
|
|
518
|
+
f"(threshold {report.pending_decisions_threshold})"
|
|
519
|
+
)
|
|
520
|
+
if report.pending_by_kind:
|
|
521
|
+
kinds = ", ".join(
|
|
522
|
+
f"{kind}={count}"
|
|
523
|
+
for kind, count in sorted(report.pending_by_kind.items())
|
|
524
|
+
)
|
|
525
|
+
lines.append(f" by kind: {kinds}")
|
|
526
|
+
if report.pending_nudge:
|
|
527
|
+
lines.append(f" {report.pending_nudge}")
|
|
528
|
+
if report.autonomy_enabled and report.autonomy is not None:
|
|
529
|
+
rec = report.autonomy
|
|
530
|
+
lines.append(
|
|
531
|
+
f" Autonomy dial (advisory-only): {rec.current_level} -> "
|
|
532
|
+
f"{rec.recommended_level} [{rec.action}]"
|
|
533
|
+
)
|
|
534
|
+
lines.append(f" {rec.rationale}")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def render_report(report: CapacityReport) -> str:
|
|
538
|
+
"""Render the :class:`CapacityReport` as a human-readable text block."""
|
|
539
|
+
lines: list[str] = []
|
|
540
|
+
lines.append("Capacity allocation (advisory, offline / filesystem-truth)")
|
|
541
|
+
lines.append(
|
|
542
|
+
f" unit: {report.unit_effective}"
|
|
543
|
+
+ (
|
|
544
|
+
f" (requested {report.unit_requested}; cost fallback active)"
|
|
545
|
+
if report.cost_fallback
|
|
546
|
+
else ""
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
lines.append(
|
|
550
|
+
f" window: trailing {report.window_days}d | "
|
|
551
|
+
f"classified completions: {report.classified_completions} "
|
|
552
|
+
f"(minSampleSize {report.min_sample_size}) | source: {report.source}"
|
|
553
|
+
)
|
|
554
|
+
# Surface the schema error when a malformed capacityAllocation block fell
|
|
555
|
+
# back to defaults (source == 'default-on-error') so the operator sees the
|
|
556
|
+
# actual reason, not just a generic "not configured" advisory line.
|
|
557
|
+
if report.policy_error:
|
|
558
|
+
lines.append(f" CONFIG ERROR: {report.policy_error}")
|
|
559
|
+
|
|
560
|
+
if report.advisory_mode:
|
|
561
|
+
lines.append(" MODE: ADVISORY -- deferring to selection ordering.")
|
|
562
|
+
for reason in report.advisory_reasons:
|
|
563
|
+
lines.append(f" - {reason}")
|
|
564
|
+
|
|
565
|
+
_append_backlog_and_autonomy(lines, report)
|
|
566
|
+
|
|
567
|
+
if not report.buckets:
|
|
568
|
+
lines.append(" (no buckets configured and no classified work on disk)")
|
|
569
|
+
return "\n".join(lines)
|
|
570
|
+
|
|
571
|
+
header = (
|
|
572
|
+
f" {'bucket':<16} {'target':>7} {'fwd':>7} {'back':>7} "
|
|
573
|
+
f"{'deficit':>8} {'rework':>7} {'cost':>18}"
|
|
574
|
+
)
|
|
575
|
+
lines.append(header)
|
|
576
|
+
lines.append(" " + "-" * (len(header) - 2))
|
|
577
|
+
for tally in report.buckets:
|
|
578
|
+
deficit = report.bucket_deficit(tally)
|
|
579
|
+
lines.append(
|
|
580
|
+
f" {tally.bucket_id:<16} "
|
|
581
|
+
f"{tally.target * 100:>6.1f}% "
|
|
582
|
+
f"{tally.forward_weight:>7.1f} "
|
|
583
|
+
f"{tally.backward_weight:>7.1f} "
|
|
584
|
+
f"{deficit:>+8.2f} "
|
|
585
|
+
f"{tally.rework_weight:>7.1f} "
|
|
586
|
+
f"{_format_cost(tally.cost_actual):>18}"
|
|
587
|
+
)
|
|
588
|
+
lines.append(
|
|
589
|
+
f" {'TOTAL':<16} {'':>7} {report.total_forward:>7.1f} "
|
|
590
|
+
f"{report.total_backward:>7.1f}"
|
|
591
|
+
)
|
|
592
|
+
if report.cost_fallback:
|
|
593
|
+
lines.append(
|
|
594
|
+
" Note: cost overlay shows none/estimate-only -- no grounded cost "
|
|
595
|
+
"telemetry (out of scope / upstream-blocked, OQ2)."
|
|
596
|
+
)
|
|
597
|
+
return "\n".join(lines)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
# CLI
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def evaluate(
|
|
606
|
+
project_root: Path, *, now: datetime | None = None
|
|
607
|
+
) -> tuple[int, CapacityReport | None, str]:
|
|
608
|
+
"""Pure entry point: returns ``(exit_code, report, rendered_text)``.
|
|
609
|
+
|
|
610
|
+
``capacity:show`` is a display surface, so the exit code is always 0 on a
|
|
611
|
+
valid project root; only an invalid ``--project-root`` yields exit 2.
|
|
612
|
+
"""
|
|
613
|
+
if not project_root.is_dir():
|
|
614
|
+
return 2, None, (
|
|
615
|
+
f"capacity_show: --project-root is not a directory: {project_root}\n"
|
|
616
|
+
" Recovery: pass an existing project root."
|
|
617
|
+
)
|
|
618
|
+
report = compute_report(project_root, now=now)
|
|
619
|
+
return 0, report, render_report(report)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
623
|
+
parser = argparse.ArgumentParser(
|
|
624
|
+
prog="capacity_show.py",
|
|
625
|
+
description=(
|
|
626
|
+
"Offline capacity-allocation accounting (#1419 Slice 4). Derives "
|
|
627
|
+
"per-bucket target-vs-actual mix from the vBRIEF lifecycle folders "
|
|
628
|
+
"with outcome (rework) and cost overlays. Advisory -- always exits 0 "
|
|
629
|
+
"on a valid project root."
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
parser.add_argument(
|
|
633
|
+
"--project-root",
|
|
634
|
+
default=".",
|
|
635
|
+
help="Project root path (default: current working directory).",
|
|
636
|
+
)
|
|
637
|
+
return parser
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def main(argv: list[str] | None = None) -> int:
|
|
641
|
+
parser = _build_parser()
|
|
642
|
+
args = parser.parse_args(argv)
|
|
643
|
+
project_root = Path(args.project_root).resolve()
|
|
644
|
+
code, _report, message = evaluate(project_root)
|
|
645
|
+
if code == 0:
|
|
646
|
+
print(message)
|
|
647
|
+
else:
|
|
648
|
+
print(message, file=sys.stderr)
|
|
649
|
+
return code
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
if __name__ == "__main__":
|
|
653
|
+
sys.exit(main())
|