@deftai/directive-content 0.58.0 → 0.60.0
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-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,1011 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""triage_summary.py -- D2 (#1122) ``task triage:summary`` one-liner.
|
|
3
|
-
|
|
4
|
-
Status surface for the session-start ritual (N9 / #1149). Reads the
|
|
5
|
-
existing unified ``.deft-cache/github-issue/<owner>/<repo>/`` cache
|
|
6
|
-
layout (`#883 Story 2`) and the operator-private ``candidates.jsonl``
|
|
7
|
-
audit log (`#845 Story 2`), derives four counts (untriaged, stale-defer,
|
|
8
|
-
in-flight, WIP-vs-cap), and prints ONE bounded (<=120 char) line in the
|
|
9
|
-
documented format. D14 (#1133) adds an optional ``[scope-drift] N``
|
|
10
|
-
segment when subscription drift is detected against the active
|
|
11
|
-
``plan.policy.triageScope[]``; suppressed at zero.
|
|
12
|
-
|
|
13
|
-
[triage] 12 untriaged ┬╖ 5 stale-defer (resume condition met) ┬╖ 8 in-flight ┬╖ WIP 12/12 ΓÜá
|
|
14
|
-
|
|
15
|
-
Behaviour contract (issue body of #1122):
|
|
16
|
-
|
|
17
|
-
- Always exits 0 -- this is a status surface, not a gate. Gates live in
|
|
18
|
-
D5 (#1127, ``task verify:cache-fresh``) and D4 (#1124, WIP cap).
|
|
19
|
-
- ``[triage] cache empty -- run task triage:bootstrap`` is emitted
|
|
20
|
-
instead of zeros when the cache directory is missing/empty, so a fresh
|
|
21
|
-
consumer install is unambiguous.
|
|
22
|
-
- Threshold-aware: the WIP warning glyph (`⚠`) only appears at-or-above
|
|
23
|
-
the cap; the ``stale-defer (resume condition met)`` field only appears
|
|
24
|
-
when at least one resume condition has fired (>=1 -- D3 / #1123 will
|
|
25
|
-
ship the resume conditions; until then the count is always 0 and the
|
|
26
|
-
field is suppressed).
|
|
27
|
-
- Truncates gracefully at 120 chars (last-field-first; never emits a
|
|
28
|
-
multi-line summary).
|
|
29
|
-
|
|
30
|
-
Every emission appends a JSONL record to
|
|
31
|
-
``vbrief/.eval/summary-history.jsonl`` (gitignored per N4 / #1144). The
|
|
32
|
-
record carries ``{schema, emitted_at, line, ...computed_fields}`` so
|
|
33
|
-
future operators can replay drift offline without re-reading the cache.
|
|
34
|
-
|
|
35
|
-
D11 follow-up (#1128): once ``task triage:audit --format=json`` ships,
|
|
36
|
-
``compute_summary`` will switch to consuming that surface verbatim. The
|
|
37
|
-
v1 reader is hand-rolled (walk the cache + read candidates.jsonl) per
|
|
38
|
-
the issue body's "v1 ships hand-rolled, D11 wrap-up is a follow-up"
|
|
39
|
-
explicit non-blocker note.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
from __future__ import annotations
|
|
43
|
-
|
|
44
|
-
import argparse
|
|
45
|
-
import contextlib
|
|
46
|
-
import json
|
|
47
|
-
import os
|
|
48
|
-
import sys
|
|
49
|
-
from collections.abc import Iterable, Mapping
|
|
50
|
-
from dataclasses import dataclass, field
|
|
51
|
-
from datetime import UTC, datetime
|
|
52
|
-
from pathlib import Path
|
|
53
|
-
from typing import Any
|
|
54
|
-
|
|
55
|
-
# Make sibling scripts importable when invoked as ``python scripts/triage_summary.py``.
|
|
56
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
57
|
-
|
|
58
|
-
# UTF-8 self-reconfigure -- the one-liner emits middle-dot (·) and the
|
|
59
|
-
# warning glyph (⚠), which cp1252 (the Windows default stdout codepage)
|
|
60
|
-
# cannot encode. Mirrors the pattern in triage_scope.py / cache.py.
|
|
61
|
-
for _stream in (sys.stdout, sys.stderr):
|
|
62
|
-
if hasattr(_stream, "reconfigure"):
|
|
63
|
-
with contextlib.suppress(AttributeError, ValueError):
|
|
64
|
-
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# ---------------------------------------------------------------------------
|
|
68
|
-
# Public constants -- documented invariants for downstream consumers.
|
|
69
|
-
# ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
#: Maximum width of the one-liner, including the leading ``[triage]``
|
|
72
|
-
#: tag. Issue #1122 freezes this at 120; truncation below this cap is
|
|
73
|
-
#: graceful (last-field-first) rather than multi-line.
|
|
74
|
-
MAX_LINE_CHARS: int = 120
|
|
75
|
-
|
|
76
|
-
# Default ``plan.policy.wipCap`` fallback when the typed field is
|
|
77
|
-
# absent / missing / non-int. **Imported** from ``scripts.policy``
|
|
78
|
-
# (#1124 / D4) -- the single source of truth so D2 and D4 cannot
|
|
79
|
-
# drift again. The shared constant resolves to ``10`` per umbrella
|
|
80
|
-
# #1119 Current Shape v3 (comment 4471269010); the value used to
|
|
81
|
-
# duplicate-literal at 12 here, matching the now-superseded D4
|
|
82
|
-
# issue-body default. Re-exported as a module attribute so existing
|
|
83
|
-
# callers / tests that reference ``triage_summary.DEFAULT_WIP_CAP``
|
|
84
|
-
# keep working without import-site churn.
|
|
85
|
-
from policy import DEFAULT_WIP_CAP as _POLICY_DEFAULT_WIP_CAP # noqa: E402
|
|
86
|
-
|
|
87
|
-
#: Re-exported alias of :data:`scripts.policy.DEFAULT_WIP_CAP` (10
|
|
88
|
-
#: per umbrella #1119 Current Shape v3). Kept as a module-level name
|
|
89
|
-
#: for callers / tests that already import it from this module.
|
|
90
|
-
DEFAULT_WIP_CAP: int = _POLICY_DEFAULT_WIP_CAP
|
|
91
|
-
|
|
92
|
-
#: Filesystem-relative location of the PROJECT-DEFINITION vBRIEF
|
|
93
|
-
#: (mirrors ``scripts/policy.py`` / ``scripts/triage_scope.py``).
|
|
94
|
-
PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
|
|
95
|
-
|
|
96
|
-
#: Cache root + source under it that triage v1 consumes. Mirrors the
|
|
97
|
-
#: layout walker in ``scripts/triage_bulk.py``.
|
|
98
|
-
CACHE_DIR_NAME: str = ".deft-cache"
|
|
99
|
-
CACHE_SOURCE: str = "github-issue"
|
|
100
|
-
|
|
101
|
-
#: Append-only audit log written by ``scripts/candidates_log.py``.
|
|
102
|
-
CANDIDATES_LOG_REL_PATH: str = "vbrief/.eval/candidates.jsonl"
|
|
103
|
-
|
|
104
|
-
#: Append-only emission history written by *this* module. Operator-private
|
|
105
|
-
#: (gitignored via N4 / #1144); used for offline replay / drift dashboards.
|
|
106
|
-
SUMMARY_HISTORY_REL_PATH: str = "vbrief/.eval/summary-history.jsonl"
|
|
107
|
-
|
|
108
|
-
#: Schema marker on every summary-history JSONL record. Bumped if the
|
|
109
|
-
#: record shape ever changes so a downstream replay tool can refuse a
|
|
110
|
-
#: shape it does not understand instead of mis-rendering.
|
|
111
|
-
SUMMARY_HISTORY_SCHEMA: str = "deft.triage.summary.v1"
|
|
112
|
-
|
|
113
|
-
#: Canonical empty-cache prompt. Emitted verbatim when the cache root
|
|
114
|
-
#: is missing or contains no ``<source>/<owner>/<repo>/<N>/`` entries.
|
|
115
|
-
EMPTY_CACHE_LINE: str = "[triage] cache empty -- run task triage:bootstrap"
|
|
116
|
-
|
|
117
|
-
#: vBRIEF lifecycle folders that count toward the WIP set. Mirrors
|
|
118
|
-
#: D4 / #1124's `pending/ + active/` cap target.
|
|
119
|
-
WIP_LIFECYCLE_DIRS: tuple[str, ...] = ("pending", "active")
|
|
120
|
-
|
|
121
|
-
#: Lifecycle folder whose ``plan.status == "running"`` vBRIEFs are
|
|
122
|
-
#: counted as the *filesystem-truth* in-flight set (#1270). The active/
|
|
123
|
-
#: folder is the single source of truth for activated work; the
|
|
124
|
-
#: audit-log decision count (``IN_FLIGHT_DECISIONS``) below is retained
|
|
125
|
-
#: only for divergence detection vs. the cache-scoped view.
|
|
126
|
-
FILESYSTEM_IN_FLIGHT_FOLDER: str = "active"
|
|
127
|
-
|
|
128
|
-
#: ``plan.status`` value that classifies an active/ vBRIEF as in-flight
|
|
129
|
-
#: under the #1270 filesystem-truth contract. The activation verb
|
|
130
|
-
#: (``task vbrief:activate``) flips this field to ``running`` when it
|
|
131
|
-
#: moves a scope into ``vbrief/active/``; any other status (``done``,
|
|
132
|
-
#: ``cancelled``, ``blocked``) MUST NOT count toward the headline.
|
|
133
|
-
FILESYSTEM_IN_FLIGHT_STATUS: str = "running"
|
|
134
|
-
|
|
135
|
-
#: Glyph appended when the WIP count meets-or-exceeds the cap. Plain
|
|
136
|
-
#: U+26A0 (no variation selector) so the byte width matches the
|
|
137
|
-
#: 120-char contract on every renderer.
|
|
138
|
-
WIP_WARN_GLYPH: str = "\u26a0"
|
|
139
|
-
|
|
140
|
-
#: Audit-log decisions that classify a cached issue as ``in-flight``.
|
|
141
|
-
#: ``accept`` is the canonical signal: the issue has entered the swarm
|
|
142
|
-
#: pipeline but is not yet rejected / closed / duplicated.
|
|
143
|
-
IN_FLIGHT_DECISIONS: frozenset[str] = frozenset({"accept"})
|
|
144
|
-
|
|
145
|
-
#: Decisions that exclude the cached issue from the ``untriaged`` count
|
|
146
|
-
#: (the issue HAS been triaged). ``reset`` is INCLUDED in untriaged
|
|
147
|
-
#: because a reset returns the issue to the unclassified state by
|
|
148
|
-
#: design (`scripts/candidates_log.py::_VALID_DECISIONS`).
|
|
149
|
-
#: ``resume-eligible`` (#1123 / D3) is a triaged state too -- the
|
|
150
|
-
#: original defer's record still stands; the marker just routes the
|
|
151
|
-
#: item into the [RESUME] queue bucket for operator review.
|
|
152
|
-
TRIAGED_DECISIONS: frozenset[str] = frozenset(
|
|
153
|
-
{
|
|
154
|
-
"accept",
|
|
155
|
-
"reject",
|
|
156
|
-
"defer",
|
|
157
|
-
"needs-ac",
|
|
158
|
-
"mark-duplicate",
|
|
159
|
-
"resume-eligible",
|
|
160
|
-
}
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
#: Decisions that count toward the ``stale-defer (resume condition
|
|
164
|
-
#: met)`` field on the one-liner. D3 (#1123) emits ``resume-eligible``
|
|
165
|
-
#: whenever a prior ``defer``'s ``resume_on`` expression fires -- the
|
|
166
|
-
#: number of cached issues whose latest decision matches IS the count.
|
|
167
|
-
STALE_DEFER_DECISIONS: frozenset[str] = frozenset({"resume-eligible"})
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
# ---------------------------------------------------------------------------
|
|
171
|
-
# Dataclasses
|
|
172
|
-
# ---------------------------------------------------------------------------
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
@dataclass(frozen=True)
|
|
176
|
-
class SummaryResult:
|
|
177
|
-
"""Structured triage summary -- the source of truth the one-liner renders.
|
|
178
|
-
|
|
179
|
-
A ``cache_empty`` summary carries all-zero numeric fields by
|
|
180
|
-
convention; renderers MUST treat the boolean as the discriminator
|
|
181
|
-
(the all-zero shape on an empty cache MUST emit the empty-cache
|
|
182
|
-
prompt, never zeros).
|
|
183
|
-
|
|
184
|
-
``scope_drift`` (D14 / #1133) is the distinct-issue count that
|
|
185
|
-
would join the cache if every currently-detected unsubscribed
|
|
186
|
-
label/milestone were opted into. Suppressed from the one-liner
|
|
187
|
-
when zero; surfaced as ``[scope-drift] N`` when positive.
|
|
188
|
-
|
|
189
|
-
``in_flight`` (#1270) is the *filesystem-truth* count: live
|
|
190
|
-
``vbrief/active/*.vbrief.json`` files with ``plan.status ==
|
|
191
|
-
"running"``. It mirrors :attr:`in_flight_filesystem` and is kept
|
|
192
|
-
under the historical name so existing call-sites / history
|
|
193
|
-
records / tests stay green. :attr:`in_flight_cache_scoped` carries
|
|
194
|
-
the legacy audit-log-derived count (cached issues whose latest
|
|
195
|
-
decision is ``accept``) -- retained only so the renderer can
|
|
196
|
-
detect divergence between the cache view and the filesystem and
|
|
197
|
-
surface a ``[triage:scope]`` line. :attr:`triage_scope_configured`
|
|
198
|
-
discriminates the two discrepancy-line variants -- ``True`` means
|
|
199
|
-
the operator has set a non-empty ``plan.policy.triageScope[]``;
|
|
200
|
-
``False`` means the framework default (``[{"rule":"all-open"}]``)
|
|
201
|
-
is in effect (or no PROJECT-DEFINITION exists).
|
|
202
|
-
"""
|
|
203
|
-
|
|
204
|
-
cache_empty: bool
|
|
205
|
-
untriaged: int
|
|
206
|
-
stale_defer: int
|
|
207
|
-
in_flight: int
|
|
208
|
-
wip_count: int
|
|
209
|
-
wip_cap: int
|
|
210
|
-
#: Sample of cached repos -- used in observability records; capped
|
|
211
|
-
#: at 8 entries so the JSONL line never blows past the
|
|
212
|
-
#: ``vbrief/.eval/summary-history.jsonl`` rolling-tail tolerance.
|
|
213
|
-
repos: tuple[str, ...] = field(default_factory=tuple)
|
|
214
|
-
#: D14 / #1133: subscription-drift count (distinct open cached
|
|
215
|
-
#: issues that would join the subscription if every surfaced
|
|
216
|
-
#: label/milestone signal were opted into). Defaults to 0 for
|
|
217
|
-
#: backward compatibility with pre-D14 callers / tests that
|
|
218
|
-
#: construct :class:`SummaryResult` directly.
|
|
219
|
-
scope_drift: int = 0
|
|
220
|
-
#: #1270: filesystem-truth in-flight count (live
|
|
221
|
-
#: ``vbrief/active/*.vbrief.json`` with ``plan.status == "running"``).
|
|
222
|
-
#: Defaults to 0 so pre-#1270 :class:`SummaryResult` constructors
|
|
223
|
-
#: in existing tests continue to work; production callers go
|
|
224
|
-
#: through :func:`compute_summary` which always sets this.
|
|
225
|
-
in_flight_filesystem: int = 0
|
|
226
|
-
#: #1270: legacy audit-log-derived in-flight count (cached issues
|
|
227
|
-
#: with latest decision ``accept``). Used only for divergence
|
|
228
|
-
#: detection against :attr:`in_flight_filesystem`.
|
|
229
|
-
in_flight_cache_scoped: int = 0
|
|
230
|
-
#: #1270: True iff ``plan.policy.triageScope`` is a non-empty list
|
|
231
|
-
#: of dict rules on PROJECT-DEFINITION (i.e. the consumer has
|
|
232
|
-
#: opted past the framework ``all-open`` default). Discriminates
|
|
233
|
-
#: the ``outside scope`` vs ``not configured`` discrepancy-line
|
|
234
|
-
#: variant.
|
|
235
|
-
triage_scope_configured: bool = False
|
|
236
|
-
#: #1468: count of cached issues currently counted ``untriaged``
|
|
237
|
-
#: (no audit decision at all) that have a matching
|
|
238
|
-
#: ``proposed/`` / ``pending/`` / ``active/`` vBRIEF carrying an
|
|
239
|
-
#: ``x-vbrief/github-issue`` reference -- i.e. the issues a
|
|
240
|
-
#: ``task triage:reconcile`` run would heal. Suppressed from the
|
|
241
|
-
#: one-liner; surfaced as a second-line ``[triage:reconcile] N``
|
|
242
|
-
#: hint (mirrors the ``[triage:scope]`` divergence line). Defaults
|
|
243
|
-
#: to 0 for pre-#1468 callers / tests that construct
|
|
244
|
-
#: :class:`SummaryResult` directly.
|
|
245
|
-
reconcilable: int = 0
|
|
246
|
-
|
|
247
|
-
def to_record(self, *, emitted_at: str, line: str) -> dict[str, Any]:
|
|
248
|
-
"""Render as the ``summary-history.jsonl`` record shape."""
|
|
249
|
-
return {
|
|
250
|
-
"schema": SUMMARY_HISTORY_SCHEMA,
|
|
251
|
-
"emitted_at": emitted_at,
|
|
252
|
-
"line": line,
|
|
253
|
-
"cache_empty": self.cache_empty,
|
|
254
|
-
"untriaged": self.untriaged,
|
|
255
|
-
"stale_defer": self.stale_defer,
|
|
256
|
-
"in_flight": self.in_flight,
|
|
257
|
-
"in_flight_filesystem": self.in_flight_filesystem,
|
|
258
|
-
"in_flight_cache_scoped": self.in_flight_cache_scoped,
|
|
259
|
-
"triage_scope_configured": self.triage_scope_configured,
|
|
260
|
-
"wip_count": self.wip_count,
|
|
261
|
-
"wip_cap": self.wip_cap,
|
|
262
|
-
"repos": list(self.repos),
|
|
263
|
-
"scope_drift": self.scope_drift,
|
|
264
|
-
"reconcilable": self.reconcilable,
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
# ---------------------------------------------------------------------------
|
|
269
|
-
# Time helpers
|
|
270
|
-
# ---------------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _utc_iso(dt: datetime | None = None) -> str:
|
|
274
|
-
"""ISO-8601 UTC with explicit ``Z`` suffix (`candidates.jsonl`-compatible)."""
|
|
275
|
-
moment = (dt or datetime.now(UTC)).astimezone(UTC)
|
|
276
|
-
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
# ---------------------------------------------------------------------------
|
|
280
|
-
# Filesystem walkers (pure-stdlib; no live gh / cache_get calls)
|
|
281
|
-
# ---------------------------------------------------------------------------
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def _is_pos_int_dir(p: Path) -> bool:
|
|
285
|
-
# ``isdecimal`` (not ``isdigit``) -- ``isdigit`` accepts the Unicode
|
|
286
|
-
# ``Numeric_Type=Digit`` class which includes superscript digits
|
|
287
|
-
# (``²`` / ``³``) and circled digits; ``int(name)`` raises
|
|
288
|
-
# ``ValueError`` on those, breaking the walker. ``isdecimal`` is the
|
|
289
|
-
# stricter ``Nd`` (Decimal_Number) match -- ASCII ``0-9`` plus other
|
|
290
|
-
# genuine decimal-class digits whose ``int()`` round-trip is total.
|
|
291
|
-
return p.is_dir() and p.name.isdecimal()
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def iter_cached_issues(cache_root: Path) -> list[tuple[str, int]]:
|
|
295
|
-
"""Walk ``<cache_root>/github-issue/<owner>/<repo>/<N>/`` cache entries.
|
|
296
|
-
|
|
297
|
-
Returns a list of ``(repo, issue_number)`` tuples where ``repo`` is
|
|
298
|
-
the canonical ``owner/name`` shape. Order is deterministic
|
|
299
|
-
(lexicographic by owner, repo, then numeric issue). Missing cache
|
|
300
|
-
root returns ``[]`` -- callers MUST treat that as the empty-cache
|
|
301
|
-
sentinel (the empty-cache prompt is owned by ``format_one_liner``).
|
|
302
|
-
|
|
303
|
-
Hardened against stray non-numeric directories under ``<repo>/``
|
|
304
|
-
(the unified cache writer never creates them but operators may
|
|
305
|
-
sometimes drop ad-hoc artefacts during debugging -- skipping them
|
|
306
|
-
keeps the count honest).
|
|
307
|
-
"""
|
|
308
|
-
base = cache_root / CACHE_SOURCE
|
|
309
|
-
if not base.is_dir():
|
|
310
|
-
return []
|
|
311
|
-
out: list[tuple[str, int]] = []
|
|
312
|
-
for owner_dir in sorted(base.iterdir(), key=lambda p: p.name):
|
|
313
|
-
if not owner_dir.is_dir():
|
|
314
|
-
continue
|
|
315
|
-
for repo_dir in sorted(owner_dir.iterdir(), key=lambda p: p.name):
|
|
316
|
-
if not repo_dir.is_dir():
|
|
317
|
-
continue
|
|
318
|
-
repo = f"{owner_dir.name}/{repo_dir.name}"
|
|
319
|
-
for issue_dir in sorted(
|
|
320
|
-
(p for p in repo_dir.iterdir() if _is_pos_int_dir(p)),
|
|
321
|
-
key=lambda p: int(p.name),
|
|
322
|
-
):
|
|
323
|
-
with contextlib.suppress(ValueError):
|
|
324
|
-
out.append((repo, int(issue_dir.name)))
|
|
325
|
-
return out
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def read_audit_log(log_path: Path) -> list[dict[str, Any]]:
|
|
329
|
-
"""Return well-formed audit-log entries in insertion order.
|
|
330
|
-
|
|
331
|
-
Tolerant reader: malformed JSON lines are skipped silently because
|
|
332
|
-
the summary surface MUST NOT crash on a torn tail from a crashed
|
|
333
|
-
appender (the same tolerance contract ``candidates_log.read_all``
|
|
334
|
-
exposes). Missing log returns ``[]``.
|
|
335
|
-
"""
|
|
336
|
-
if not log_path.is_file():
|
|
337
|
-
return []
|
|
338
|
-
out: list[dict[str, Any]] = []
|
|
339
|
-
try:
|
|
340
|
-
text = log_path.read_text(encoding="utf-8")
|
|
341
|
-
except (OSError, UnicodeDecodeError):
|
|
342
|
-
return []
|
|
343
|
-
for raw in text.splitlines():
|
|
344
|
-
stripped = raw.strip()
|
|
345
|
-
if not stripped:
|
|
346
|
-
continue
|
|
347
|
-
try:
|
|
348
|
-
obj = json.loads(stripped)
|
|
349
|
-
except json.JSONDecodeError:
|
|
350
|
-
continue
|
|
351
|
-
if isinstance(obj, dict):
|
|
352
|
-
out.append(obj)
|
|
353
|
-
return out
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def latest_decisions(entries: Iterable[Mapping[str, Any]]) -> dict[tuple[str, int], str]:
|
|
357
|
-
"""Collapse audit-log entries to ``{(repo, issue_number): decision}``.
|
|
358
|
-
|
|
359
|
-
Sort key is the entry's ``timestamp`` field -- ISO-8601 UTC with the
|
|
360
|
-
``Z`` suffix sorts lexicographically in chronological order, so a
|
|
361
|
-
string sort is correct for every compliant timestamp produced by
|
|
362
|
-
``candidates_log.append``. Entries missing ``repo`` /
|
|
363
|
-
``issue_number`` / ``decision`` are skipped (tolerance contract).
|
|
364
|
-
"""
|
|
365
|
-
rows: list[tuple[str, str, int, str]] = []
|
|
366
|
-
for entry in entries:
|
|
367
|
-
repo = entry.get("repo")
|
|
368
|
-
issue_number = entry.get("issue_number")
|
|
369
|
-
decision = entry.get("decision")
|
|
370
|
-
timestamp = entry.get("timestamp", "")
|
|
371
|
-
if (
|
|
372
|
-
not isinstance(repo, str)
|
|
373
|
-
or not isinstance(issue_number, int)
|
|
374
|
-
or isinstance(issue_number, bool)
|
|
375
|
-
or not isinstance(decision, str)
|
|
376
|
-
or not isinstance(timestamp, str)
|
|
377
|
-
):
|
|
378
|
-
continue
|
|
379
|
-
rows.append((timestamp, repo, issue_number, decision))
|
|
380
|
-
rows.sort(key=lambda r: r[0])
|
|
381
|
-
out: dict[tuple[str, int], str] = {}
|
|
382
|
-
for _ts, repo, n, decision in rows:
|
|
383
|
-
out[(repo, n)] = decision
|
|
384
|
-
return out
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
# ---------------------------------------------------------------------------
|
|
388
|
-
# vBRIEF WIP counters + typed-cap reader
|
|
389
|
-
# ---------------------------------------------------------------------------
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def count_vbrief_wip(project_root: Path) -> int:
|
|
393
|
-
"""Count vBRIEFs in ``vbrief/pending/`` + ``vbrief/active/``.
|
|
394
|
-
|
|
395
|
-
Files are filtered by ``.vbrief.json`` suffix so non-vBRIEF
|
|
396
|
-
artefacts dropped into the lifecycle folders by accident (README
|
|
397
|
-
scratch, hand-authored notes) do not pollute the count. Missing
|
|
398
|
-
folders contribute 0. Mirrors the D4 / #1124 cap target.
|
|
399
|
-
"""
|
|
400
|
-
total = 0
|
|
401
|
-
vbrief_root = project_root / "vbrief"
|
|
402
|
-
for sub in WIP_LIFECYCLE_DIRS:
|
|
403
|
-
folder = vbrief_root / sub
|
|
404
|
-
if not folder.is_dir():
|
|
405
|
-
continue
|
|
406
|
-
total += sum(
|
|
407
|
-
1
|
|
408
|
-
for child in folder.iterdir()
|
|
409
|
-
if child.is_file() and child.name.endswith(".vbrief.json")
|
|
410
|
-
)
|
|
411
|
-
return total
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def count_filesystem_in_flight(project_root: Path) -> int:
|
|
415
|
-
"""Count *filesystem-truth* in-flight vBRIEFs (#1270).
|
|
416
|
-
|
|
417
|
-
Walks ``vbrief/active/*.vbrief.json``, parses each, and counts
|
|
418
|
-
those whose ``plan.status`` equals
|
|
419
|
-
:data:`FILESYSTEM_IN_FLIGHT_STATUS` (``"running"``). Tolerant of:
|
|
420
|
-
|
|
421
|
-
* Missing ``vbrief/active/`` folder -- contributes 0.
|
|
422
|
-
* Malformed JSON files -- skipped (per the D2 "never crash the
|
|
423
|
-
ritual" contract; mirrors :func:`read_audit_log`).
|
|
424
|
-
* Files where ``plan`` / ``plan.status`` is absent or a non-string
|
|
425
|
-
-- counted as NOT running (excluded from the total).
|
|
426
|
-
* Non-``.vbrief.json`` files in the folder -- ignored (same
|
|
427
|
-
sidecar-tolerance as :func:`count_vbrief_wip`).
|
|
428
|
-
|
|
429
|
-
This is the new primary source of truth for the ritual's
|
|
430
|
-
``in-flight`` headline. The legacy audit-log-derived count
|
|
431
|
-
(cached issues with latest decision ``accept``) is retained in
|
|
432
|
-
:func:`compute_summary` for divergence detection only.
|
|
433
|
-
"""
|
|
434
|
-
folder = project_root / "vbrief" / FILESYSTEM_IN_FLIGHT_FOLDER
|
|
435
|
-
if not folder.is_dir():
|
|
436
|
-
return 0
|
|
437
|
-
total = 0
|
|
438
|
-
for child in folder.iterdir():
|
|
439
|
-
if not (child.is_file() and child.name.endswith(".vbrief.json")):
|
|
440
|
-
continue
|
|
441
|
-
# The whole parse is wrapped so a corrupt vBRIEF (torn write,
|
|
442
|
-
# bad encoding, OS-level read refusal) does not crash the
|
|
443
|
-
# ritual. The cost of a missed count is far less than the cost
|
|
444
|
-
# of a session-start exception.
|
|
445
|
-
with contextlib.suppress(Exception):
|
|
446
|
-
data = json.loads(child.read_text(encoding="utf-8"))
|
|
447
|
-
if not isinstance(data, dict):
|
|
448
|
-
continue
|
|
449
|
-
plan = data.get("plan")
|
|
450
|
-
if not isinstance(plan, dict):
|
|
451
|
-
continue
|
|
452
|
-
status = plan.get("status")
|
|
453
|
-
if isinstance(status, str) and status == FILESYSTEM_IN_FLIGHT_STATUS:
|
|
454
|
-
total += 1
|
|
455
|
-
return total
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def _is_triage_scope_explicitly_configured(project_root: Path) -> bool:
|
|
459
|
-
"""Return ``True`` iff ``plan.policy.triageScope`` is a non-empty
|
|
460
|
-
list of dict rules on PROJECT-DEFINITION.
|
|
461
|
-
|
|
462
|
-
Discriminator for the #1270 discrepancy-line variant:
|
|
463
|
-
|
|
464
|
-
* ``True`` -> ``[triage:scope] N in-flight outside
|
|
465
|
-
plan.policy.triageScope[] (uncounted in queue ranking)``.
|
|
466
|
-
* ``False`` -> ``[triage:scope] N in-flight; plan.policy.triageScope[]
|
|
467
|
-
not configured (uncounted in queue ranking)``.
|
|
468
|
-
|
|
469
|
-
The framework default (``[{"rule": "all-open"}]``) and the absent /
|
|
470
|
-
empty / malformed cases all surface as "not configured" -- the
|
|
471
|
-
operator hasn't tightened scope, so the discrepancy line nudges
|
|
472
|
-
them toward configuring it rather than implying their explicit
|
|
473
|
-
config is wrong.
|
|
474
|
-
|
|
475
|
-
Tolerant of every failure mode (missing file, malformed JSON,
|
|
476
|
-
non-dict shapes) -- a config-read failure must NOT crash the
|
|
477
|
-
ritual; we fall back to ``False`` so the "not configured" wording
|
|
478
|
-
fires (the conservative reading).
|
|
479
|
-
"""
|
|
480
|
-
path = project_root / PROJECT_DEFINITION_REL_PATH
|
|
481
|
-
if not path.is_file():
|
|
482
|
-
return False
|
|
483
|
-
try:
|
|
484
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
485
|
-
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
486
|
-
return False
|
|
487
|
-
if not isinstance(data, dict):
|
|
488
|
-
return False
|
|
489
|
-
plan = data.get("plan")
|
|
490
|
-
if not isinstance(plan, dict):
|
|
491
|
-
return False
|
|
492
|
-
policy = plan.get("policy")
|
|
493
|
-
if not isinstance(policy, dict):
|
|
494
|
-
return False
|
|
495
|
-
scope = policy.get("triageScope")
|
|
496
|
-
if not isinstance(scope, list) or not scope:
|
|
497
|
-
return False
|
|
498
|
-
# At least one rule must be a dict for the field to count as
|
|
499
|
-
# "configured" -- a list of non-dicts is malformed config and
|
|
500
|
-
# collapses to the same "not configured" path.
|
|
501
|
-
return any(isinstance(rule, dict) for rule in scope)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def resolve_wip_cap(project_root: Path) -> int:
|
|
505
|
-
"""Read ``plan.policy.wipCap`` from PROJECT-DEFINITION; fall back to the framework default.
|
|
506
|
-
|
|
507
|
-
D4 (#1124) ships the canonical resolver as
|
|
508
|
-
:func:`scripts.policy.resolve_wip_cap` (returns a ``WipCapResult``).
|
|
509
|
-
D2's surface here is a thin shim that returns the integer cap only,
|
|
510
|
-
preserving the original :func:`triage_summary.resolve_wip_cap`
|
|
511
|
-
return contract -- existing call-sites continue to work without
|
|
512
|
-
pattern-matching on ``source``. The shared constant
|
|
513
|
-
:data:`DEFAULT_WIP_CAP` is imported from ``scripts.policy`` (D4)
|
|
514
|
-
so D2 and D4 cannot drift again -- the post-#1119 Current Shape
|
|
515
|
-
v3 override (10) lives in ONE place. Defers to D4's resolver for
|
|
516
|
-
the actual read so all the malformed-JSON / non-int /
|
|
517
|
-
missing-PROJECT-DEFINITION tolerance lives in one place too.
|
|
518
|
-
"""
|
|
519
|
-
# Lazy-import the D4 resolver under ``contextlib.suppress`` so a
|
|
520
|
-
# partial install (D4 not present on a pre-#1124 branch) still
|
|
521
|
-
# produces a sensible default. Mirrors the lazy-hook pattern in
|
|
522
|
-
# scripts/vbrief_validate.py.
|
|
523
|
-
try:
|
|
524
|
-
from policy import resolve_wip_cap as _resolve_wip_cap_d4 # noqa: I001
|
|
525
|
-
result = _resolve_wip_cap_d4(project_root)
|
|
526
|
-
return int(result.cap)
|
|
527
|
-
except ImportError: # pragma: no cover -- D4 not present on rolling-merge tolerance branch
|
|
528
|
-
return DEFAULT_WIP_CAP
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
# ---------------------------------------------------------------------------
|
|
532
|
-
# compute / format / persist
|
|
533
|
-
# ---------------------------------------------------------------------------
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def compute_summary(
|
|
537
|
-
project_root: Path,
|
|
538
|
-
*,
|
|
539
|
-
cache_root: Path | None = None,
|
|
540
|
-
audit_log_path: Path | None = None,
|
|
541
|
-
) -> SummaryResult:
|
|
542
|
-
"""Derive the structured triage summary from on-disk state.
|
|
543
|
-
|
|
544
|
-
Hand-rolled reader per the issue body's D11-soft-dependency clause.
|
|
545
|
-
Switch to ``task triage:audit --format=json`` (#1128) once D11
|
|
546
|
-
lands -- the function signature is the contract, the internals are
|
|
547
|
-
free to change.
|
|
548
|
-
"""
|
|
549
|
-
resolved_cache_root = cache_root or (project_root / CACHE_DIR_NAME)
|
|
550
|
-
resolved_log_path = audit_log_path or (project_root / CANDIDATES_LOG_REL_PATH)
|
|
551
|
-
|
|
552
|
-
cached = iter_cached_issues(resolved_cache_root)
|
|
553
|
-
repos = sorted({repo for repo, _n in cached})
|
|
554
|
-
wip_cap = resolve_wip_cap(project_root)
|
|
555
|
-
wip_count = count_vbrief_wip(project_root)
|
|
556
|
-
# #1270: the filesystem-truth in-flight count is the new headline
|
|
557
|
-
# source. Computed unconditionally (even on empty cache) so a
|
|
558
|
-
# consumer who has activated work before bootstrapping the cache
|
|
559
|
-
# still sees their actual WIP reflected in observability records.
|
|
560
|
-
in_flight_filesystem = count_filesystem_in_flight(project_root)
|
|
561
|
-
triage_scope_configured = _is_triage_scope_explicitly_configured(project_root)
|
|
562
|
-
|
|
563
|
-
if not cached:
|
|
564
|
-
# Cache empty -- the renderer emits the canonical empty-cache
|
|
565
|
-
# prompt regardless of the numeric counts. We still surface
|
|
566
|
-
# the filesystem count via :attr:`in_flight_filesystem` and
|
|
567
|
-
# :attr:`in_flight` so downstream observability / JSON
|
|
568
|
-
# consumers see truthful values; ``in_flight_cache_scoped``
|
|
569
|
-
# stays at 0 because there's no cache view to disagree with.
|
|
570
|
-
return SummaryResult(
|
|
571
|
-
cache_empty=True,
|
|
572
|
-
untriaged=0,
|
|
573
|
-
stale_defer=0,
|
|
574
|
-
in_flight=in_flight_filesystem,
|
|
575
|
-
wip_count=wip_count,
|
|
576
|
-
wip_cap=wip_cap,
|
|
577
|
-
repos=tuple(repos[:8]),
|
|
578
|
-
scope_drift=0,
|
|
579
|
-
in_flight_filesystem=in_flight_filesystem,
|
|
580
|
-
in_flight_cache_scoped=0,
|
|
581
|
-
triage_scope_configured=triage_scope_configured,
|
|
582
|
-
)
|
|
583
|
-
|
|
584
|
-
entries = read_audit_log(resolved_log_path)
|
|
585
|
-
decisions = latest_decisions(entries)
|
|
586
|
-
|
|
587
|
-
untriaged = 0
|
|
588
|
-
in_flight_cache_scoped = 0
|
|
589
|
-
stale_defer = 0
|
|
590
|
-
# #1468: cached issues with NO audit decision at all -- the subset of
|
|
591
|
-
# ``untriaged`` that ``task triage:reconcile`` can heal when a
|
|
592
|
-
# matching on-disk vBRIEF exists. ``reset`` / other non-triaged
|
|
593
|
-
# decisions are deliberate operator actions and are NOT collected
|
|
594
|
-
# here (reconcile never overrides a real decision).
|
|
595
|
-
no_decision_keys: list[tuple[str, int]] = []
|
|
596
|
-
for repo, issue_number in cached:
|
|
597
|
-
decision = decisions.get((repo, issue_number))
|
|
598
|
-
if decision is None or decision == "reset" or decision not in TRIAGED_DECISIONS:
|
|
599
|
-
# ``reset`` is non-skipping by design (see candidates_log
|
|
600
|
-
# docstring) so a reset-back-to-untriaged is correctly
|
|
601
|
-
# counted in the untriaged bucket.
|
|
602
|
-
untriaged += 1
|
|
603
|
-
if decision is None:
|
|
604
|
-
no_decision_keys.append((repo, issue_number))
|
|
605
|
-
elif decision in IN_FLIGHT_DECISIONS:
|
|
606
|
-
# #1270: this count is now the *cache-scoped* view, used
|
|
607
|
-
# only for divergence detection against the
|
|
608
|
-
# filesystem-truth count above. The headline
|
|
609
|
-
# :attr:`in_flight` is filesystem-truth.
|
|
610
|
-
in_flight_cache_scoped += 1
|
|
611
|
-
if decision in STALE_DEFER_DECISIONS:
|
|
612
|
-
# D3 (#1123): cached issues whose latest decision is
|
|
613
|
-
# ``resume-eligible`` ARE the count the one-liner surfaces.
|
|
614
|
-
# Pre-D3 audit logs cannot emit ``resume-eligible`` so the
|
|
615
|
-
# count stays at zero on a checkout that has not yet rebased
|
|
616
|
-
# onto D3 -- back-compat preserved.
|
|
617
|
-
stale_defer += 1
|
|
618
|
-
|
|
619
|
-
scope_drift = _read_scope_drift_total(project_root, resolved_cache_root)
|
|
620
|
-
reconcilable = _read_reconcilable_total(
|
|
621
|
-
project_root, resolved_log_path, no_decision_keys
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
return SummaryResult(
|
|
625
|
-
cache_empty=False,
|
|
626
|
-
untriaged=untriaged,
|
|
627
|
-
stale_defer=stale_defer,
|
|
628
|
-
# #1270: ``in_flight`` is now an alias for the filesystem-truth
|
|
629
|
-
# count. The cache-scoped count surfaces only via
|
|
630
|
-
# :attr:`in_flight_cache_scoped` and the discrepancy line.
|
|
631
|
-
in_flight=in_flight_filesystem,
|
|
632
|
-
wip_count=wip_count,
|
|
633
|
-
wip_cap=wip_cap,
|
|
634
|
-
repos=tuple(repos[:8]),
|
|
635
|
-
scope_drift=scope_drift,
|
|
636
|
-
in_flight_filesystem=in_flight_filesystem,
|
|
637
|
-
in_flight_cache_scoped=in_flight_cache_scoped,
|
|
638
|
-
triage_scope_configured=triage_scope_configured,
|
|
639
|
-
reconcilable=reconcilable,
|
|
640
|
-
)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
def _read_reconcilable_total(
|
|
644
|
-
project_root: Path,
|
|
645
|
-
audit_log_path: Path,
|
|
646
|
-
no_decision_keys: list[tuple[str, int]],
|
|
647
|
-
) -> int:
|
|
648
|
-
"""Return the #1468 reconcilable count -- 0 on any import / runtime failure.
|
|
649
|
-
|
|
650
|
-
Intersects the cached, currently-untriaged-because-no-decision issues
|
|
651
|
-
(``no_decision_keys``) with the set ``task triage:reconcile`` would
|
|
652
|
-
heal (proposed/pending/active vBRIEFs carrying an
|
|
653
|
-
``x-vbrief/github-issue`` reference with no audit entry). The reconcile
|
|
654
|
-
detector lives at ``scripts/triage_reconcile.py`` and is read-only.
|
|
655
|
-
Failures (missing module, malformed vBRIEFs, etc.) silently degrade to
|
|
656
|
-
0 so the one-liner contract (always exits 0) is preserved -- mirrors
|
|
657
|
-
:func:`_read_scope_drift_total`.
|
|
658
|
-
"""
|
|
659
|
-
if not no_decision_keys:
|
|
660
|
-
return 0
|
|
661
|
-
# Derive the fallback repo from the cached keys themselves so the hint
|
|
662
|
-
# stays in sync with what ``task triage:reconcile`` would restore for a
|
|
663
|
-
# bare-URI vBRIEF (one whose github-issue reference omits owner/repo).
|
|
664
|
-
# When every cached untriaged issue shares one repo we pass it as the
|
|
665
|
-
# default; a mixed-repo cache passes ``None`` (the rare bare-URI case
|
|
666
|
-
# is then conservatively skipped). Using the cache's authoritative repo
|
|
667
|
-
# avoids a git-remote subprocess on the session-start hot path.
|
|
668
|
-
repos = {repo for repo, _n in no_decision_keys}
|
|
669
|
-
default_repo = next(iter(repos)) if len(repos) == 1 else None
|
|
670
|
-
try:
|
|
671
|
-
from triage_reconcile import count_reconcilable # noqa: I001
|
|
672
|
-
return int(
|
|
673
|
-
count_reconcilable(
|
|
674
|
-
project_root,
|
|
675
|
-
default_repo=default_repo,
|
|
676
|
-
audit_log_path=audit_log_path,
|
|
677
|
-
restrict_to=no_decision_keys,
|
|
678
|
-
)
|
|
679
|
-
)
|
|
680
|
-
except Exception: # pragma: no cover -- broad on purpose; status surface
|
|
681
|
-
return 0
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
def _read_scope_drift_total(project_root: Path, cache_root: Path) -> int:
|
|
685
|
-
"""Return the D14 (#1133) drift total -- 0 on any import / runtime failure.
|
|
686
|
-
|
|
687
|
-
The drift detector lives at ``scripts/triage_scope_drift.py`` and
|
|
688
|
-
is read-only; computing the total here is a cheap re-walk of the
|
|
689
|
-
same cache the summary already touched. Failures (missing module,
|
|
690
|
-
malformed PROJECT-DEFINITION, etc.) silently degrade to 0 so the
|
|
691
|
-
one-liner contract (always exits 0) is preserved.
|
|
692
|
-
"""
|
|
693
|
-
try:
|
|
694
|
-
from triage_scope_drift import compute_drift # noqa: I001
|
|
695
|
-
report = compute_drift(project_root, cache_root=cache_root)
|
|
696
|
-
return int(report.total)
|
|
697
|
-
except Exception: # pragma: no cover -- broad on purpose; status surface
|
|
698
|
-
return 0
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
def _truncate(text: str, max_chars: int) -> str:
|
|
702
|
-
"""Hard truncate ``text`` to at most ``max_chars`` glyphs.
|
|
703
|
-
|
|
704
|
-
Cuts on a character boundary; appends ``...`` only when there is
|
|
705
|
-
room for the ellipsis without exceeding the cap. The output is
|
|
706
|
-
guaranteed to be a single line (no embedded newlines) and at most
|
|
707
|
-
``max_chars`` Python characters wide. Falls back to a bare slice
|
|
708
|
-
when the cap is too small for the ellipsis (we never lose the
|
|
709
|
-
leading ``[triage]`` tag).
|
|
710
|
-
"""
|
|
711
|
-
if len(text) <= max_chars:
|
|
712
|
-
return text
|
|
713
|
-
if max_chars <= 3:
|
|
714
|
-
return text[:max_chars]
|
|
715
|
-
return text[: max_chars - 3] + "..."
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
def format_one_liner(result: SummaryResult, *, max_chars: int = MAX_LINE_CHARS) -> str:
|
|
719
|
-
"""Render the structured summary as the documented one-liner.
|
|
720
|
-
|
|
721
|
-
Format (#1122)::
|
|
722
|
-
|
|
723
|
-
[triage] N untriaged [· S stale-defer (resume condition met)] · M in-flight · WIP X/Y [⚠]
|
|
724
|
-
|
|
725
|
-
Rules:
|
|
726
|
-
|
|
727
|
-
* Empty cache emits the canonical empty-cache prompt verbatim,
|
|
728
|
-
ignoring numeric fields entirely.
|
|
729
|
-
* The stale-defer block appears only when ``stale_defer >= 1``.
|
|
730
|
-
* The WIP warning glyph appears only when ``wip_count >= wip_cap``.
|
|
731
|
-
* ``0 untriaged`` STILL prints (zero is a healthy signal, not
|
|
732
|
-
silence -- issue body).
|
|
733
|
-
* Truncation drops the lowest-impact bits first (warning glyph,
|
|
734
|
-
then stale-defer block) before resorting to a hard ellipsis cut.
|
|
735
|
-
"""
|
|
736
|
-
if result.cache_empty:
|
|
737
|
-
return _truncate(EMPTY_CACHE_LINE, max_chars)
|
|
738
|
-
|
|
739
|
-
parts = [f"[triage] {result.untriaged} untriaged"]
|
|
740
|
-
if result.stale_defer >= 1:
|
|
741
|
-
parts.append(f"{result.stale_defer} stale-defer (resume condition met)")
|
|
742
|
-
parts.append(f"{result.in_flight} in-flight")
|
|
743
|
-
wip_field = f"WIP {result.wip_count}/{result.wip_cap}"
|
|
744
|
-
if result.wip_count >= result.wip_cap:
|
|
745
|
-
wip_field = f"{wip_field} {WIP_WARN_GLYPH}"
|
|
746
|
-
parts.append(wip_field)
|
|
747
|
-
# D14 / #1133: `[scope-drift] N` is suppressed at 0; surfaced last
|
|
748
|
-
# so truncation drops it BEFORE the WIP cap field (the cap is a
|
|
749
|
-
# gate signal; drift is informational).
|
|
750
|
-
if result.scope_drift > 0:
|
|
751
|
-
parts.append(f"[scope-drift] {result.scope_drift}")
|
|
752
|
-
|
|
753
|
-
candidate = " \u00b7 ".join(parts)
|
|
754
|
-
if len(candidate) <= max_chars:
|
|
755
|
-
return candidate
|
|
756
|
-
|
|
757
|
-
# Graceful field-by-field shedding before falling back to a hard
|
|
758
|
-
# truncate. Last-impact-first: drop the warning glyph, then the
|
|
759
|
-
# stale-defer block, then truncate.
|
|
760
|
-
if WIP_WARN_GLYPH in wip_field:
|
|
761
|
-
wip_field_no_warn = f"WIP {result.wip_count}/{result.wip_cap}"
|
|
762
|
-
rebuilt = list(parts)
|
|
763
|
-
rebuilt[-1] = wip_field_no_warn
|
|
764
|
-
candidate = " \u00b7 ".join(rebuilt)
|
|
765
|
-
if len(candidate) <= max_chars:
|
|
766
|
-
return candidate
|
|
767
|
-
|
|
768
|
-
if result.stale_defer >= 1:
|
|
769
|
-
rebuilt = [
|
|
770
|
-
parts[0],
|
|
771
|
-
f"{result.in_flight} in-flight",
|
|
772
|
-
f"WIP {result.wip_count}/{result.wip_cap}",
|
|
773
|
-
]
|
|
774
|
-
candidate = " \u00b7 ".join(rebuilt)
|
|
775
|
-
if len(candidate) <= max_chars:
|
|
776
|
-
return candidate
|
|
777
|
-
|
|
778
|
-
return _truncate(candidate, max_chars)
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
def format_scope_discrepancy_line(result: SummaryResult) -> str | None:
|
|
782
|
-
"""Return the ``[triage:scope]`` discrepancy line, or ``None`` if aligned.
|
|
783
|
-
|
|
784
|
-
Emitted when the filesystem-truth in-flight count diverges from the
|
|
785
|
-
cache-scoped audit-log count (#1270). Two wording variants -- the
|
|
786
|
-
canonical strings are defined inline in the function body below:
|
|
787
|
-
|
|
788
|
-
* ``triage_scope_configured = True`` -> ``outside
|
|
789
|
-
plan.policy.triageScope[]`` wording (operator has set a non-empty
|
|
790
|
-
``plan.policy.triageScope[]``).
|
|
791
|
-
* ``triage_scope_configured = False`` -> ``not configured`` wording
|
|
792
|
-
(framework default ``all-open`` OR absent / empty / malformed
|
|
793
|
-
config).
|
|
794
|
-
|
|
795
|
-
``N`` is the *absolute* delta between the two counts. Returns
|
|
796
|
-
``None`` (no second line) when the counts agree -- the common case
|
|
797
|
-
when scope is aligned. Cache-empty summaries also return ``None``
|
|
798
|
-
because the headline switches to ``EMPTY_CACHE_LINE`` and the
|
|
799
|
-
discrepancy semantics no longer apply.
|
|
800
|
-
"""
|
|
801
|
-
if result.cache_empty:
|
|
802
|
-
return None
|
|
803
|
-
delta = abs(result.in_flight_filesystem - result.in_flight_cache_scoped)
|
|
804
|
-
if delta == 0:
|
|
805
|
-
return None
|
|
806
|
-
if result.triage_scope_configured:
|
|
807
|
-
return (
|
|
808
|
-
f"[triage:scope] {delta} in-flight outside "
|
|
809
|
-
"plan.policy.triageScope[] (uncounted in queue ranking)"
|
|
810
|
-
)
|
|
811
|
-
return (
|
|
812
|
-
f"[triage:scope] {delta} in-flight; "
|
|
813
|
-
"plan.policy.triageScope[] not configured "
|
|
814
|
-
"(uncounted in queue ranking)"
|
|
815
|
-
)
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
def format_reconcile_hint_line(result: SummaryResult) -> str | None:
|
|
819
|
-
"""Return the ``[triage:reconcile]`` hint line, or ``None`` if aligned.
|
|
820
|
-
|
|
821
|
-
Emitted (#1468) when ``result.reconcilable`` is positive -- i.e. one
|
|
822
|
-
or more cached issues are counted as ``untriaged`` (no audit
|
|
823
|
-
decision) yet a matching ``proposed/`` / ``pending/`` / ``active/``
|
|
824
|
-
vBRIEF carrying an ``x-vbrief/github-issue`` reference exists on
|
|
825
|
-
disk. Those issues were accepted (the surviving vBRIEF is the proof)
|
|
826
|
-
but their audit-log decision was lost; the line points the operator
|
|
827
|
-
at the discoverable repair verb. Mirrors the
|
|
828
|
-
:func:`format_scope_discrepancy_line` second-line pattern.
|
|
829
|
-
|
|
830
|
-
Returns ``None`` (no line) when ``reconcilable == 0`` -- the common
|
|
831
|
-
case once the audit log and the on-disk inventory agree, and always
|
|
832
|
-
on a cache-empty summary (the headline switches to
|
|
833
|
-
``EMPTY_CACHE_LINE`` and there is nothing cached to reconcile).
|
|
834
|
-
"""
|
|
835
|
-
if result.cache_empty or result.reconcilable <= 0:
|
|
836
|
-
return None
|
|
837
|
-
return (
|
|
838
|
-
f"[triage:reconcile] {result.reconcilable} accepted on disk but "
|
|
839
|
-
"missing from the audit log -- run `task triage:reconcile` to restore"
|
|
840
|
-
)
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
def format_summary(result: SummaryResult, *, max_chars: int = MAX_LINE_CHARS) -> str:
|
|
844
|
-
"""Render the full (possibly multi-line) summary string.
|
|
845
|
-
|
|
846
|
-
Composes the headline one-liner (delegated to
|
|
847
|
-
:func:`format_one_liner`, which retains the original
|
|
848
|
-
single-physical-line + 120-char-cap contract from #1122) plus,
|
|
849
|
-
when applicable, a second physical line produced by
|
|
850
|
-
:func:`format_scope_discrepancy_line` (#1270).
|
|
851
|
-
|
|
852
|
-
The 120-char cap is applied per physical line, not to the combined
|
|
853
|
-
string -- the discrepancy / reconcile lines are informational and
|
|
854
|
-
intentionally longer than the cap would allow when collapsed into
|
|
855
|
-
one line. CLI callers print this verbatim; the history-JSONL
|
|
856
|
-
``line`` field also receives the full multi-line content so offline
|
|
857
|
-
replay sees the same view the operator did.
|
|
858
|
-
|
|
859
|
-
Line order (when present): headline, then the #1270
|
|
860
|
-
``[triage:scope]`` divergence line, then the #1468
|
|
861
|
-
``[triage:reconcile]`` hint line.
|
|
862
|
-
"""
|
|
863
|
-
lines = [format_one_liner(result, max_chars=max_chars)]
|
|
864
|
-
scope_line = format_scope_discrepancy_line(result)
|
|
865
|
-
if scope_line is not None:
|
|
866
|
-
lines.append(scope_line)
|
|
867
|
-
reconcile_line = format_reconcile_hint_line(result)
|
|
868
|
-
if reconcile_line is not None:
|
|
869
|
-
lines.append(reconcile_line)
|
|
870
|
-
return "\n".join(lines)
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
def append_history(
|
|
874
|
-
history_path: Path,
|
|
875
|
-
result: SummaryResult,
|
|
876
|
-
line: str,
|
|
877
|
-
*,
|
|
878
|
-
emitted_at: str | None = None,
|
|
879
|
-
) -> Path:
|
|
880
|
-
"""Append a single JSONL record to ``summary-history.jsonl``.
|
|
881
|
-
|
|
882
|
-
Pure-stdlib write through ``open(..., "a", encoding="utf-8")`` so
|
|
883
|
-
the append is atomic on standard filesystems (no read-modify-write
|
|
884
|
-
-- aligns with ``scripts/policy.py::append_audit_log``). Parent
|
|
885
|
-
directory is created if missing (fresh consumer installs).
|
|
886
|
-
Failures are silenced via :func:`contextlib.suppress` because the
|
|
887
|
-
history sidecar is observability, not load-bearing for the summary
|
|
888
|
-
surface itself; a corrupt sidecar MUST NOT crash session start.
|
|
889
|
-
"""
|
|
890
|
-
record = result.to_record(
|
|
891
|
-
emitted_at=emitted_at or _utc_iso(),
|
|
892
|
-
line=line,
|
|
893
|
-
)
|
|
894
|
-
payload = json.dumps(record, sort_keys=True, ensure_ascii=False)
|
|
895
|
-
# Greptile P1 fix: ``mkdir`` is INSIDE the suppress block so a
|
|
896
|
-
# permission-denied / read-only-fs / SELinux refusal on the parent
|
|
897
|
-
# ``vbrief/.eval/`` directory never propagates out of the helper.
|
|
898
|
-
# ``append_history`` MUST never raise -- the sidecar is observability
|
|
899
|
-
# only, the issue body freezes the verb's exit code at 0 in every
|
|
900
|
-
# scenario.
|
|
901
|
-
with contextlib.suppress(OSError):
|
|
902
|
-
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
903
|
-
with open(history_path, "a", encoding="utf-8", newline="") as handle:
|
|
904
|
-
handle.write(payload + "\n")
|
|
905
|
-
handle.flush()
|
|
906
|
-
with contextlib.suppress(OSError):
|
|
907
|
-
os.fsync(handle.fileno())
|
|
908
|
-
return history_path
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
# ---------------------------------------------------------------------------
|
|
912
|
-
# CLI
|
|
913
|
-
# ---------------------------------------------------------------------------
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
def _resolve_project_root(raw: str | None) -> Path:
|
|
917
|
-
if raw:
|
|
918
|
-
return Path(raw).resolve()
|
|
919
|
-
return Path.cwd().resolve()
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
923
|
-
parser = argparse.ArgumentParser(
|
|
924
|
-
prog="triage_summary",
|
|
925
|
-
description=(
|
|
926
|
-
"Emit the D2 (#1122) `task triage:summary` one-liner. Always "
|
|
927
|
-
"exits 0; appends a JSONL record to "
|
|
928
|
-
"vbrief/.eval/summary-history.jsonl as a side effect."
|
|
929
|
-
),
|
|
930
|
-
)
|
|
931
|
-
parser.add_argument(
|
|
932
|
-
"--project-root",
|
|
933
|
-
default=None,
|
|
934
|
-
help=(
|
|
935
|
-
"Project root to inspect (defaults to the current working "
|
|
936
|
-
"directory). The Taskfile dispatch threads "
|
|
937
|
-
"{{.USER_WORKING_DIR}} through here so the verb works in "
|
|
938
|
-
"consumer worktrees regardless of where the framework is "
|
|
939
|
-
"installed."
|
|
940
|
-
),
|
|
941
|
-
)
|
|
942
|
-
parser.add_argument(
|
|
943
|
-
"--cache-root",
|
|
944
|
-
default=None,
|
|
945
|
-
help=(
|
|
946
|
-
"Override the cache root location (default: "
|
|
947
|
-
"<project-root>/.deft-cache). Used by tests; production "
|
|
948
|
-
"callers MUST NOT pass this."
|
|
949
|
-
),
|
|
950
|
-
)
|
|
951
|
-
parser.add_argument(
|
|
952
|
-
"--no-history",
|
|
953
|
-
action="store_true",
|
|
954
|
-
help=(
|
|
955
|
-
"Suppress the summary-history.jsonl append (read-only "
|
|
956
|
-
"rendering). Used by tests; production callers SHOULD NOT "
|
|
957
|
-
"pass this -- the history sidecar is the observability "
|
|
958
|
-
"surface."
|
|
959
|
-
),
|
|
960
|
-
)
|
|
961
|
-
parser.add_argument(
|
|
962
|
-
"--json",
|
|
963
|
-
action="store_true",
|
|
964
|
-
help=(
|
|
965
|
-
"Emit the structured summary record as JSON on stdout "
|
|
966
|
-
"instead of the human-readable one-liner. The history "
|
|
967
|
-
"sidecar still receives a record (unless --no-history)."
|
|
968
|
-
),
|
|
969
|
-
)
|
|
970
|
-
return parser
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
def main(argv: list[str] | None = None) -> int:
|
|
974
|
-
"""CLI entrypoint -- always returns 0 (status surface, not a gate)."""
|
|
975
|
-
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
976
|
-
from triage_help import intercept_help
|
|
977
|
-
|
|
978
|
-
rc = intercept_help("triage_summary", argv)
|
|
979
|
-
if rc is not None:
|
|
980
|
-
return rc
|
|
981
|
-
parser = _build_parser()
|
|
982
|
-
args = parser.parse_args(argv)
|
|
983
|
-
project_root = _resolve_project_root(args.project_root)
|
|
984
|
-
cache_root = Path(args.cache_root).resolve() if args.cache_root else None
|
|
985
|
-
|
|
986
|
-
result = compute_summary(project_root, cache_root=cache_root)
|
|
987
|
-
# #1270: ``format_summary`` returns the headline plus, when
|
|
988
|
-
# filesystem-vs-cache counts diverge, a second ``[triage:scope]``
|
|
989
|
-
# line. The headline retains the #1122 single-line + 120-char-cap
|
|
990
|
-
# contract via :func:`format_one_liner`.
|
|
991
|
-
line = format_summary(result)
|
|
992
|
-
emitted_at = _utc_iso()
|
|
993
|
-
|
|
994
|
-
if args.json:
|
|
995
|
-
record = result.to_record(emitted_at=emitted_at, line=line)
|
|
996
|
-
print(json.dumps(record, sort_keys=True, ensure_ascii=False))
|
|
997
|
-
else:
|
|
998
|
-
print(line)
|
|
999
|
-
|
|
1000
|
-
if not args.no_history:
|
|
1001
|
-
history_path = project_root / SUMMARY_HISTORY_REL_PATH
|
|
1002
|
-
append_history(history_path, result, line, emitted_at=emitted_at)
|
|
1003
|
-
|
|
1004
|
-
# Issue #1122 freezes the exit code at 0 for every scenario. The
|
|
1005
|
-
# verb is a status surface, not a gate; downstream gates own their
|
|
1006
|
-
# own exit-code contracts (D5 verify:cache-fresh, D4 WIP cap).
|
|
1007
|
-
return 0
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
if __name__ == "__main__": # pragma: no cover -- thin shim
|
|
1011
|
-
raise SystemExit(main())
|