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