@deftai/directive-content 0.58.0 → 0.60.0

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