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