@deftai/directive-content 0.55.2 → 0.56.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-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,581 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""triage_reconcile.py -- idempotent triage audit-log self-heal (#1468).
|
|
3
|
+
|
|
4
|
+
The triage audit log ``vbrief/.eval/candidates.jsonl`` is the single
|
|
5
|
+
source of truth for "has issue #N been triaged?", yet it is
|
|
6
|
+
operator-private and gitignored (#1464) so branch churn or a
|
|
7
|
+
``vbrief/.eval/`` cleanup can silently wipe / reset it. When that
|
|
8
|
+
happens, ``proposed/`` / ``pending/`` / ``active/`` vBRIEFs that carry a
|
|
9
|
+
valid ``x-vbrief/github-issue`` reference are left with **no matching
|
|
10
|
+
``accept`` decision** in the log -- an internally inconsistent state
|
|
11
|
+
that ``task triage:summary`` faithfully (but confusingly) counts as
|
|
12
|
+
``untriaged``.
|
|
13
|
+
|
|
14
|
+
The only prior path that re-derived the lost accepts was a full
|
|
15
|
+
``task triage:bootstrap`` re-run, which also re-fetches the upstream
|
|
16
|
+
cache and is not discoverable as a "repair" action. This module promotes
|
|
17
|
+
the bootstrap backfill logic into a standalone, discoverable, idempotent
|
|
18
|
+
repair verb -- ``task triage:reconcile`` -- that derives the missing
|
|
19
|
+
``accept`` decisions from the on-disk vBRIEF inventory **without a cache
|
|
20
|
+
re-fetch**.
|
|
21
|
+
|
|
22
|
+
Semantics (mirrors ``triage_bootstrap.step_backfill_audit_log``):
|
|
23
|
+
|
|
24
|
+
- Scans ``vbrief/proposed/`` + ``vbrief/pending/`` + ``vbrief/active/``
|
|
25
|
+
(``BACKFILL_FOLDERS``). ``cancelled/`` and ``completed/`` are NOT
|
|
26
|
+
scanned -- a cancelled item must not be reanimated and completed work
|
|
27
|
+
is out of the triage funnel.
|
|
28
|
+
- For each vBRIEF carrying an ``x-vbrief/github-issue`` reference, the
|
|
29
|
+
``(repo, issue_number)`` is parsed from the reference URI itself, so
|
|
30
|
+
reconcile works even when no ``--repo`` is supplied and ``git remote``
|
|
31
|
+
is unavailable (the filesystem inventory is the recoverable source).
|
|
32
|
+
- An ``accept`` decision is appended ONLY when ``(repo, issue_number)``
|
|
33
|
+
has **no existing entry** in the audit log. Any prior decision
|
|
34
|
+
(``accept`` / ``reject`` / ``defer`` / ``reset`` / ...) is left
|
|
35
|
+
untouched -- reconcile never overrides a real operator decision, so a
|
|
36
|
+
re-run is a no-op.
|
|
37
|
+
|
|
38
|
+
Exit codes (three-state, mirrors ``scripts/triage_bootstrap.py``):
|
|
39
|
+
|
|
40
|
+
- ``0`` -- reconcile completed (or was a no-op on a re-run).
|
|
41
|
+
- ``1`` -- a runtime step failed (e.g. the audit-log append raised).
|
|
42
|
+
- ``2`` -- config error: ``--project-root`` does not exist / is not a
|
|
43
|
+
directory.
|
|
44
|
+
|
|
45
|
+
Refs:
|
|
46
|
+
|
|
47
|
+
- #1468 (this verb -- audit-log <-> proposed-folder reconciliation).
|
|
48
|
+
- #1464 (sibling: the audit log is gitignored, hence silently wipeable).
|
|
49
|
+
- #845 Story 2 (the ``candidates.jsonl`` audit log this verb repairs).
|
|
50
|
+
- #883 Story 3 (``triage_bootstrap.step_backfill_audit_log``, the
|
|
51
|
+
point-in-time backfill this verb promotes into a repair path).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import contextlib
|
|
58
|
+
import json
|
|
59
|
+
import os
|
|
60
|
+
import sys
|
|
61
|
+
from collections.abc import Iterable, Mapping
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
# Make sibling ``scripts`` modules importable when invoked as
|
|
67
|
+
# ``python scripts/triage_reconcile.py`` from the project root.
|
|
68
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
69
|
+
|
|
70
|
+
# UTF-8 self-reconfigure -- the recap prints ✓ / ✗ glyphs that the
|
|
71
|
+
# Windows cp1252 default stdout codepage cannot encode (mirrors the
|
|
72
|
+
# pattern in triage_bootstrap.py / triage_summary.py).
|
|
73
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
74
|
+
if hasattr(_stream, "reconfigure"):
|
|
75
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
76
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
77
|
+
|
|
78
|
+
# Reuse the canonical lifecycle-folder scan + constants from the
|
|
79
|
+
# bootstrap module so the reconcile path and the bootstrap backfill stay
|
|
80
|
+
# in lockstep (the issue body explicitly asks to "promote the existing
|
|
81
|
+
# bootstrap backfill logic"). Importing the private helpers here is the
|
|
82
|
+
# intended reuse seam -- a divergent re-implementation is the failure
|
|
83
|
+
# mode #1468 warns about.
|
|
84
|
+
from triage_bootstrap import ( # noqa: E402
|
|
85
|
+
AUDIT_LOG_RELPATH,
|
|
86
|
+
BACKFILL_FOLDERS,
|
|
87
|
+
_infer_repo_from_git,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
#: Canonical actor stamped on reconcile-emitted backfill entries. Kept
|
|
91
|
+
#: distinct from ``triage_bootstrap.BOOTSTRAP_ACTOR`` (``agent:bootstrap``)
|
|
92
|
+
#: so the audit trail records WHICH path re-derived the decision.
|
|
93
|
+
RECONCILE_ACTOR: str = "agent:reconcile"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ReconcileItem:
|
|
98
|
+
"""A single ``(repo, issue_number)`` slated for an ``accept`` backfill."""
|
|
99
|
+
|
|
100
|
+
repo: str
|
|
101
|
+
issue_number: int
|
|
102
|
+
folder: str
|
|
103
|
+
path: Path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ReconcileResult:
|
|
108
|
+
"""Aggregate result returned by :func:`reconcile`."""
|
|
109
|
+
|
|
110
|
+
project_root: Path
|
|
111
|
+
default_repo: str | None
|
|
112
|
+
restored: int = 0
|
|
113
|
+
skipped_existing: int = 0
|
|
114
|
+
skipped_no_repo: int = 0
|
|
115
|
+
dry_run: bool = False
|
|
116
|
+
items: list[ReconcileItem] = field(default_factory=list)
|
|
117
|
+
error: str | None = None
|
|
118
|
+
exit_code: int = 0
|
|
119
|
+
|
|
120
|
+
def summary(self) -> str:
|
|
121
|
+
"""Render the human-readable recap the operator sees."""
|
|
122
|
+
verb = "would restore" if self.dry_run else "restored"
|
|
123
|
+
mark = "✓" if self.exit_code == 0 else "✗"
|
|
124
|
+
lines = ["", "Triage audit-log reconcile recap:"]
|
|
125
|
+
lines.append(
|
|
126
|
+
f" {mark} {verb} {self.restored} accept decision(s) from on-disk "
|
|
127
|
+
f"vBRIEFs; skipped {self.skipped_existing} (already in audit log)"
|
|
128
|
+
)
|
|
129
|
+
if self.skipped_no_repo:
|
|
130
|
+
lines.append(
|
|
131
|
+
f" skipped {self.skipped_no_repo} vBRIEF(s) with no "
|
|
132
|
+
"resolvable repo (no owner/name in the github-issue reference "
|
|
133
|
+
"and no --repo / git remote fallback)"
|
|
134
|
+
)
|
|
135
|
+
if self.error:
|
|
136
|
+
lines.append(f" error: {self.error}")
|
|
137
|
+
if self.items:
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append(" Issues reconciled:")
|
|
140
|
+
for item in self.items:
|
|
141
|
+
lines.append(
|
|
142
|
+
f" #{item.issue_number} ({item.repo}) "
|
|
143
|
+
f"<- vbrief/{item.folder}/"
|
|
144
|
+
)
|
|
145
|
+
if self.exit_code == 0 and not self.items and not self.dry_run:
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append(
|
|
148
|
+
" Nothing to reconcile -- the audit log already covers every "
|
|
149
|
+
"in-scope vBRIEF."
|
|
150
|
+
)
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# vBRIEF reference parsing
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_github_issue_uri(uri: str) -> tuple[str | None, int | None]:
|
|
160
|
+
"""Parse ``(repo, issue_number)`` from a github-issue reference URI.
|
|
161
|
+
|
|
162
|
+
Accepts the canonical
|
|
163
|
+
``https://github.com/OWNER/REPO/issues/N`` shape (with or without a
|
|
164
|
+
scheme / trailing slash) and returns ``("OWNER/REPO", N)``. When the
|
|
165
|
+
owner/repo segments are not present but the trailing path component
|
|
166
|
+
is numeric, returns ``(None, N)`` so the caller can fall back to a
|
|
167
|
+
``--repo`` / git-remote resolved default. Anything else is
|
|
168
|
+
``(None, None)``.
|
|
169
|
+
"""
|
|
170
|
+
if not isinstance(uri, str):
|
|
171
|
+
return None, None
|
|
172
|
+
cleaned = uri.strip().rstrip("/")
|
|
173
|
+
if not cleaned:
|
|
174
|
+
return None, None
|
|
175
|
+
# Drop the scheme so http/https/ssh-style forms parse identically.
|
|
176
|
+
no_scheme = cleaned.split("://", 1)[-1]
|
|
177
|
+
parts = [p for p in no_scheme.split("/") if p]
|
|
178
|
+
# Expected tail: [..., owner, repo, "issues", "N"].
|
|
179
|
+
if len(parts) >= 4 and parts[-2] == "issues":
|
|
180
|
+
tail = parts[-1]
|
|
181
|
+
if tail.isdigit():
|
|
182
|
+
owner = parts[-4]
|
|
183
|
+
repo = parts[-3]
|
|
184
|
+
if owner and repo:
|
|
185
|
+
return f"{owner}/{repo}", int(tail)
|
|
186
|
+
# Fallback: bare numeric tail with no resolvable owner/repo.
|
|
187
|
+
tail = parts[-1] if parts else ""
|
|
188
|
+
if tail.isdigit():
|
|
189
|
+
return None, int(tail)
|
|
190
|
+
return None, None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_issue_ref(vbrief_data: Mapping[str, Any]) -> tuple[str | None, int | None]:
|
|
194
|
+
"""Pull ``(repo, issue_number)`` from a scope vBRIEF's references[]."""
|
|
195
|
+
plan = vbrief_data.get("plan")
|
|
196
|
+
if not isinstance(plan, dict):
|
|
197
|
+
return None, None
|
|
198
|
+
refs = plan.get("references")
|
|
199
|
+
if not isinstance(refs, list):
|
|
200
|
+
return None, None
|
|
201
|
+
for ref in refs:
|
|
202
|
+
if not isinstance(ref, dict):
|
|
203
|
+
continue
|
|
204
|
+
if ref.get("type") != "x-vbrief/github-issue":
|
|
205
|
+
continue
|
|
206
|
+
repo, number = _parse_github_issue_uri(ref.get("uri", ""))
|
|
207
|
+
if number is not None:
|
|
208
|
+
return repo, number
|
|
209
|
+
return None, None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _scan_lifecycle_refs(folder: Path) -> list[tuple[str | None, int, Path]]:
|
|
213
|
+
"""Walk a lifecycle folder -> ``(repo_or_none, issue_number, path)`` tuples."""
|
|
214
|
+
results: list[tuple[str | None, int, Path]] = []
|
|
215
|
+
if not folder.exists() or not folder.is_dir():
|
|
216
|
+
return results
|
|
217
|
+
for path in sorted(folder.glob("*.vbrief.json")):
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
220
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
221
|
+
continue
|
|
222
|
+
if not isinstance(data, dict):
|
|
223
|
+
continue
|
|
224
|
+
repo, number = _extract_issue_ref(data)
|
|
225
|
+
if number is None:
|
|
226
|
+
continue
|
|
227
|
+
results.append((repo, number, path))
|
|
228
|
+
return results
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Audit-log read helpers
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _existing_audit_refs(audit_path: Path) -> set[tuple[str, int]]:
|
|
237
|
+
"""Return ``{(repo, issue_number)}`` already present in the audit log.
|
|
238
|
+
|
|
239
|
+
Keying by ``(repo, issue_number)`` (not bare issue number) matches
|
|
240
|
+
``triage_summary.latest_decisions`` so reconcile heals exactly the
|
|
241
|
+
issues the summary counts as untriaged-because-no-entry. Tolerant of
|
|
242
|
+
a missing log (returns ``set()``) and malformed lines (skipped).
|
|
243
|
+
"""
|
|
244
|
+
if not audit_path.exists():
|
|
245
|
+
return set()
|
|
246
|
+
seen: set[tuple[str, int]] = set()
|
|
247
|
+
try:
|
|
248
|
+
text = audit_path.read_text(encoding="utf-8")
|
|
249
|
+
except (OSError, UnicodeDecodeError):
|
|
250
|
+
return set()
|
|
251
|
+
for raw in text.splitlines():
|
|
252
|
+
stripped = raw.strip()
|
|
253
|
+
if not stripped:
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
entry = json.loads(stripped)
|
|
257
|
+
except json.JSONDecodeError:
|
|
258
|
+
continue
|
|
259
|
+
if not isinstance(entry, dict):
|
|
260
|
+
continue
|
|
261
|
+
repo = entry.get("repo")
|
|
262
|
+
number = entry.get("issue_number")
|
|
263
|
+
if (
|
|
264
|
+
isinstance(repo, str)
|
|
265
|
+
and isinstance(number, int)
|
|
266
|
+
and not isinstance(number, bool)
|
|
267
|
+
):
|
|
268
|
+
seen.add((repo, number))
|
|
269
|
+
return seen
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Core reconcile logic
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def find_reconcilable(
|
|
278
|
+
project_root: Path,
|
|
279
|
+
*,
|
|
280
|
+
default_repo: str | None = None,
|
|
281
|
+
audit_log_path: Path | None = None,
|
|
282
|
+
) -> list[ReconcileItem]:
|
|
283
|
+
"""Return the vBRIEFs that need an ``accept`` backfill.
|
|
284
|
+
|
|
285
|
+
A vBRIEF is reconcilable when it lives in ``proposed/`` /
|
|
286
|
+
``pending/`` / ``active/``, carries a valid ``x-vbrief/github-issue``
|
|
287
|
+
reference, and its ``(repo, issue_number)`` has **no** existing entry
|
|
288
|
+
in the audit log. ``repo`` is taken from the reference URI when
|
|
289
|
+
present, else from ``default_repo``. vBRIEFs whose repo cannot be
|
|
290
|
+
resolved are excluded (they surface as ``skipped_no_repo`` in
|
|
291
|
+
:func:`reconcile`). Read-only -- safe for the summary hint.
|
|
292
|
+
"""
|
|
293
|
+
audit_path = audit_log_path or (project_root / AUDIT_LOG_RELPATH)
|
|
294
|
+
existing = _existing_audit_refs(audit_path)
|
|
295
|
+
vbrief_root = project_root / "vbrief"
|
|
296
|
+
|
|
297
|
+
items: list[ReconcileItem] = []
|
|
298
|
+
seen: set[tuple[str, int]] = set()
|
|
299
|
+
for folder_name in BACKFILL_FOLDERS:
|
|
300
|
+
folder_path = vbrief_root / folder_name
|
|
301
|
+
for ref_repo, number, path in _scan_lifecycle_refs(folder_path):
|
|
302
|
+
effective_repo = ref_repo or default_repo
|
|
303
|
+
if effective_repo is None:
|
|
304
|
+
continue
|
|
305
|
+
key = (effective_repo, number)
|
|
306
|
+
if key in existing or key in seen:
|
|
307
|
+
continue
|
|
308
|
+
seen.add(key)
|
|
309
|
+
items.append(
|
|
310
|
+
ReconcileItem(
|
|
311
|
+
repo=effective_repo,
|
|
312
|
+
issue_number=number,
|
|
313
|
+
folder=folder_name,
|
|
314
|
+
path=path,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
return items
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _count_no_repo(
|
|
321
|
+
project_root: Path,
|
|
322
|
+
*,
|
|
323
|
+
default_repo: str | None,
|
|
324
|
+
audit_log_path: Path | None,
|
|
325
|
+
) -> int:
|
|
326
|
+
"""Count reconcilable-looking vBRIEFs whose repo cannot be resolved."""
|
|
327
|
+
audit_path = audit_log_path or (project_root / AUDIT_LOG_RELPATH)
|
|
328
|
+
existing_numbers = {n for _r, n in _existing_audit_refs(audit_path)}
|
|
329
|
+
vbrief_root = project_root / "vbrief"
|
|
330
|
+
count = 0
|
|
331
|
+
for folder_name in BACKFILL_FOLDERS:
|
|
332
|
+
for ref_repo, number, _path in _scan_lifecycle_refs(vbrief_root / folder_name):
|
|
333
|
+
if (ref_repo or default_repo) is None and number not in existing_numbers:
|
|
334
|
+
count += 1
|
|
335
|
+
return count
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _build_reconcile_entry(repo: str, issue_number: int, source_folder: str) -> dict[str, Any]:
|
|
339
|
+
"""Compose a single ``accept`` audit entry for a reconciled issue."""
|
|
340
|
+
from candidates_log import new_decision_id
|
|
341
|
+
from triage_bootstrap import _now_iso
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"decision_id": new_decision_id(),
|
|
345
|
+
"timestamp": _now_iso(),
|
|
346
|
+
"repo": repo,
|
|
347
|
+
"issue_number": issue_number,
|
|
348
|
+
"decision": "accept",
|
|
349
|
+
"actor": RECONCILE_ACTOR,
|
|
350
|
+
"reason": (
|
|
351
|
+
f"reconcile backfill (#1468): vBRIEF present in vbrief/{source_folder}/ "
|
|
352
|
+
"with a github-issue reference but no prior decision in the audit log"
|
|
353
|
+
),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def reconcile(
|
|
358
|
+
project_root: Path,
|
|
359
|
+
*,
|
|
360
|
+
repo: str | None = None,
|
|
361
|
+
audit_log_path: Path | None = None,
|
|
362
|
+
dry_run: bool = False,
|
|
363
|
+
) -> ReconcileResult:
|
|
364
|
+
"""Backfill missing ``accept`` decisions from the on-disk vBRIEF inventory.
|
|
365
|
+
|
|
366
|
+
Idempotent: only ``(repo, issue_number)`` pairs with no existing
|
|
367
|
+
audit entry are written, so a second invocation is a no-op. Repo
|
|
368
|
+
resolution precedence for vBRIEFs whose reference URI lacks an
|
|
369
|
+
owner/repo segment: explicit ``repo`` arg -> ``git remote get-url
|
|
370
|
+
origin`` inference.
|
|
371
|
+
"""
|
|
372
|
+
default_repo = repo
|
|
373
|
+
if default_repo is None:
|
|
374
|
+
default_repo = _infer_repo_from_git(cwd=project_root)
|
|
375
|
+
|
|
376
|
+
audit_path = audit_log_path or (project_root / AUDIT_LOG_RELPATH)
|
|
377
|
+
result = ReconcileResult(
|
|
378
|
+
project_root=project_root, default_repo=default_repo, dry_run=dry_run
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
items = find_reconcilable(
|
|
382
|
+
project_root, default_repo=default_repo, audit_log_path=audit_path
|
|
383
|
+
)
|
|
384
|
+
result.skipped_existing = _count_skipped_existing(
|
|
385
|
+
project_root, default_repo=default_repo, audit_log_path=audit_path
|
|
386
|
+
)
|
|
387
|
+
result.skipped_no_repo = _count_no_repo(
|
|
388
|
+
project_root, default_repo=default_repo, audit_log_path=audit_path
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if dry_run:
|
|
392
|
+
result.items = items
|
|
393
|
+
result.restored = len(items)
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
from candidates_log import append as candidates_append
|
|
397
|
+
|
|
398
|
+
restored = 0
|
|
399
|
+
for item in items:
|
|
400
|
+
entry = _build_reconcile_entry(item.repo, item.issue_number, item.folder)
|
|
401
|
+
try:
|
|
402
|
+
candidates_append(entry, path=audit_path)
|
|
403
|
+
except Exception as exc: # noqa: BLE001 -- surface honestly, do not swallow
|
|
404
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
405
|
+
result.restored = restored
|
|
406
|
+
result.items = items[:restored]
|
|
407
|
+
result.exit_code = 1
|
|
408
|
+
return result
|
|
409
|
+
restored += 1
|
|
410
|
+
|
|
411
|
+
result.restored = restored
|
|
412
|
+
result.items = items
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _count_skipped_existing(
|
|
417
|
+
project_root: Path,
|
|
418
|
+
*,
|
|
419
|
+
default_repo: str | None,
|
|
420
|
+
audit_log_path: Path | None,
|
|
421
|
+
) -> int:
|
|
422
|
+
"""Count in-scope vBRIEFs whose ``(repo, issue)`` already has an entry."""
|
|
423
|
+
audit_path = audit_log_path or (project_root / AUDIT_LOG_RELPATH)
|
|
424
|
+
existing = _existing_audit_refs(audit_path)
|
|
425
|
+
vbrief_root = project_root / "vbrief"
|
|
426
|
+
count = 0
|
|
427
|
+
counted: set[tuple[str, int]] = set()
|
|
428
|
+
for folder_name in BACKFILL_FOLDERS:
|
|
429
|
+
for ref_repo, number, _path in _scan_lifecycle_refs(vbrief_root / folder_name):
|
|
430
|
+
effective_repo = ref_repo or default_repo
|
|
431
|
+
if effective_repo is None:
|
|
432
|
+
continue
|
|
433
|
+
key = (effective_repo, number)
|
|
434
|
+
if key in existing and key not in counted:
|
|
435
|
+
counted.add(key)
|
|
436
|
+
count += 1
|
|
437
|
+
return count
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def count_reconcilable(
|
|
441
|
+
project_root: Path,
|
|
442
|
+
*,
|
|
443
|
+
default_repo: str | None = None,
|
|
444
|
+
audit_log_path: Path | None = None,
|
|
445
|
+
restrict_to: Iterable[tuple[str, int]] | None = None,
|
|
446
|
+
) -> int:
|
|
447
|
+
"""Return the number of reconcilable ``(repo, issue)`` pairs.
|
|
448
|
+
|
|
449
|
+
Read-only convenience used by ``triage_summary`` to surface the
|
|
450
|
+
``[triage:reconcile] N`` divergence hint. ``default_repo`` is plumbed
|
|
451
|
+
straight through to :func:`find_reconcilable` so the count stays in
|
|
452
|
+
sync with what :func:`reconcile` would actually restore -- without it,
|
|
453
|
+
a bare-URI vBRIEF (whose github-issue reference omits owner/repo)
|
|
454
|
+
would be silently skipped here while the verb (which resolves a
|
|
455
|
+
fallback repo) would restore it. ``restrict_to`` (when provided)
|
|
456
|
+
intersects the reconcilable set with a caller-supplied set of
|
|
457
|
+
``(repo, issue_number)`` keys -- the summary passes its cached,
|
|
458
|
+
currently-untriaged issues so the hint counts only the issues it is
|
|
459
|
+
actually miscounting.
|
|
460
|
+
"""
|
|
461
|
+
items = find_reconcilable(
|
|
462
|
+
project_root, default_repo=default_repo, audit_log_path=audit_log_path
|
|
463
|
+
)
|
|
464
|
+
keys = {(item.repo, item.issue_number) for item in items}
|
|
465
|
+
if restrict_to is not None:
|
|
466
|
+
keys &= set(restrict_to)
|
|
467
|
+
return len(keys)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# CLI
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _emit_json(result: ReconcileResult) -> str:
|
|
476
|
+
payload = {
|
|
477
|
+
"project_root": str(result.project_root),
|
|
478
|
+
"default_repo": result.default_repo,
|
|
479
|
+
"dry_run": result.dry_run,
|
|
480
|
+
"restored": result.restored,
|
|
481
|
+
"skipped_existing": result.skipped_existing,
|
|
482
|
+
"skipped_no_repo": result.skipped_no_repo,
|
|
483
|
+
"exit_code": result.exit_code,
|
|
484
|
+
"error": result.error,
|
|
485
|
+
"items": [
|
|
486
|
+
{
|
|
487
|
+
"repo": item.repo,
|
|
488
|
+
"issue_number": item.issue_number,
|
|
489
|
+
"folder": item.folder,
|
|
490
|
+
}
|
|
491
|
+
for item in result.items
|
|
492
|
+
],
|
|
493
|
+
}
|
|
494
|
+
return json.dumps(payload, sort_keys=True)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
498
|
+
parser = argparse.ArgumentParser(
|
|
499
|
+
prog="triage_reconcile.py",
|
|
500
|
+
description=(
|
|
501
|
+
"Idempotent triage audit-log self-heal (#1468). Derives missing "
|
|
502
|
+
"`accept` decisions for proposed/pending/active vBRIEFs that carry "
|
|
503
|
+
"an x-vbrief/github-issue reference but have no entry in "
|
|
504
|
+
"vbrief/.eval/candidates.jsonl -- no cache re-fetch required."
|
|
505
|
+
),
|
|
506
|
+
)
|
|
507
|
+
parser.add_argument(
|
|
508
|
+
"--project-root",
|
|
509
|
+
default=os.environ.get("DEFT_PROJECT_ROOT", "."),
|
|
510
|
+
help=(
|
|
511
|
+
"Path to the consumer project root (default: $DEFT_PROJECT_ROOT or "
|
|
512
|
+
"current working directory)."
|
|
513
|
+
),
|
|
514
|
+
)
|
|
515
|
+
parser.add_argument(
|
|
516
|
+
"--repo",
|
|
517
|
+
default=os.environ.get("DEFT_TRIAGE_REPO"),
|
|
518
|
+
help=(
|
|
519
|
+
"Fallback repo slug 'owner/name' used ONLY when a vBRIEF's "
|
|
520
|
+
"github-issue reference URI lacks an owner/repo segment -- the "
|
|
521
|
+
"per-vBRIEF URI is always the primary source and is NOT overridden "
|
|
522
|
+
"by this flag. Fallback precedence when the URI lacks owner/repo: "
|
|
523
|
+
"(1) this flag; (2) DEFT_TRIAGE_REPO env; "
|
|
524
|
+
"(3) `git remote get-url origin`."
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
parser.add_argument(
|
|
528
|
+
"--dry-run",
|
|
529
|
+
action="store_true",
|
|
530
|
+
dest="dry_run",
|
|
531
|
+
help=(
|
|
532
|
+
"Report what would be reconciled without writing any audit entries."
|
|
533
|
+
),
|
|
534
|
+
)
|
|
535
|
+
parser.add_argument(
|
|
536
|
+
"--json",
|
|
537
|
+
action="store_true",
|
|
538
|
+
dest="emit_json",
|
|
539
|
+
help=(
|
|
540
|
+
"Emit a structured JSON payload to stdout instead of the "
|
|
541
|
+
"human-readable recap. Exit code is unchanged."
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
return parser
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def main(argv: list[str] | None = None) -> int:
|
|
548
|
+
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
549
|
+
from triage_help import intercept_help
|
|
550
|
+
|
|
551
|
+
rc = intercept_help("triage_reconcile", argv)
|
|
552
|
+
if rc is not None:
|
|
553
|
+
return rc
|
|
554
|
+
parser = _build_parser()
|
|
555
|
+
args = parser.parse_args(argv)
|
|
556
|
+
|
|
557
|
+
project_root = Path(args.project_root).resolve()
|
|
558
|
+
if not project_root.exists() or not project_root.is_dir():
|
|
559
|
+
print(
|
|
560
|
+
f"❌ triage:reconcile: --project-root {project_root} does not exist "
|
|
561
|
+
"or is not a directory.",
|
|
562
|
+
file=sys.stderr,
|
|
563
|
+
)
|
|
564
|
+
return 2
|
|
565
|
+
|
|
566
|
+
result = reconcile(
|
|
567
|
+
project_root,
|
|
568
|
+
repo=args.repo,
|
|
569
|
+
dry_run=args.dry_run,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if args.emit_json:
|
|
573
|
+
print(_emit_json(result))
|
|
574
|
+
else:
|
|
575
|
+
print(result.summary())
|
|
576
|
+
|
|
577
|
+
return result.exit_code
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
if __name__ == "__main__":
|
|
581
|
+
raise SystemExit(main())
|