@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.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. 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())