@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,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())