@deftai/directive-content 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,674 @@
1
+ """_triage_smoketest_stages.py -- 9-stage assertion helpers for triage_smoketest.py.
2
+
3
+ Extracted from ``scripts/triage_smoketest.py`` so the driver stays under
4
+ the 1000-line MUST cap (coding/coding.md). Each ``stage_*`` function
5
+ takes the project root, the smoketest's :class:`AssertLog`, and any
6
+ prior-stage output it needs, and raises :class:`SmoketestFailure` on
7
+ the first assertion that fails.
8
+
9
+ The functions deliberately keep their own subprocess calls so the
10
+ driver can compose them in any order (current order matches the issue
11
+ body's demoability block 1..9).
12
+
13
+ Refs:
14
+
15
+ * Umbrella: #1119
16
+ * This deliverable: #1146 (N6)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from datetime import datetime, timedelta
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Any
28
+ from uuid import uuid4
29
+
30
+ if TYPE_CHECKING:
31
+ from triage_smoketest import AssertLog
32
+
33
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
34
+ sys.path.insert(0, str(_SCRIPTS_DIR))
35
+
36
+ FIXTURE_REPO = "deftai/smoketest"
37
+ WARN_GLYPH = "\u26a0"
38
+ SUMMARY_MAX_CHARS = 120
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Subprocess helper
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def run_script(
47
+ script_name: str,
48
+ *cli_args: str,
49
+ project_root: Path,
50
+ extra_env: dict[str, str] | None = None,
51
+ include_repo_env: bool = True,
52
+ ) -> subprocess.CompletedProcess[str]:
53
+ """Run a sibling script with ``cli_args`` against ``project_root``.
54
+
55
+ ``include_repo_env`` controls whether ``DEFT_TRIAGE_REPO`` is injected
56
+ into the subprocess env. Stages that explicitly pass ``--repo`` (audit
57
+ / queue / defer) leave it on for redundancy; the bootstrap stage
58
+ turns it off so ``triage_bootstrap.py``'s populate_cache step skips
59
+ cleanly (no --repo, no env fallback, no .git -- so the watchdog has
60
+ nothing to attempt).
61
+ """
62
+ env = dict(os.environ)
63
+ env["PYTHONUTF8"] = "1"
64
+ env["DEFT_PROJECT_ROOT"] = str(project_root)
65
+ if include_repo_env:
66
+ env["DEFT_TRIAGE_REPO"] = FIXTURE_REPO
67
+ else:
68
+ env.pop("DEFT_TRIAGE_REPO", None)
69
+ if extra_env:
70
+ env.update(extra_env)
71
+ cmd = [sys.executable, str(_SCRIPTS_DIR / script_name), *cli_args]
72
+ return subprocess.run( # noqa: S603 -- known scripts, env-controlled paths
73
+ cmd,
74
+ cwd=str(project_root),
75
+ env=env,
76
+ capture_output=True,
77
+ text=True,
78
+ encoding="utf-8",
79
+ errors="replace",
80
+ check=False,
81
+ )
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Stage 1: bootstrap + auto-classify
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def stage_bootstrap_and_classify(
90
+ project_root: Path,
91
+ issues_spec: dict[str, Any],
92
+ log: AssertLog,
93
+ ) -> None:
94
+ """Run triage:bootstrap, then emulate the D10 auto-classify apply-step.
95
+
96
+ D10 / #1129 landed the universal rules + ``classify_issue`` library but
97
+ did NOT yet wire the apply-step into bootstrap (deferred to a follow-up
98
+ child). The smoketest emulates the eventual apply-step in-process so
99
+ the assertion targets are reachable today.
100
+ """
101
+ stage = 1
102
+ name = "bootstrap + auto-classify"
103
+
104
+ bootstrap = run_script(
105
+ "triage_bootstrap.py",
106
+ "--project-root", str(project_root),
107
+ "--quiet",
108
+ "--json",
109
+ project_root=project_root,
110
+ include_repo_env=False,
111
+ )
112
+ if bootstrap.returncode != 0:
113
+ raise log.fail(
114
+ stage, name,
115
+ expected="bootstrap exit 0",
116
+ actual=f"exit {bootstrap.returncode}",
117
+ cause="triage_bootstrap.py failed: " + bootstrap.stderr.strip()[:200],
118
+ )
119
+
120
+ from candidates_log import append as audit_append # noqa: PLC0415
121
+ from triage_classify import ( # noqa: PLC0415
122
+ classify_issue,
123
+ extract_referenced_issues,
124
+ resolve_classify_rules,
125
+ resolve_hold_markers,
126
+ )
127
+
128
+ rules = resolve_classify_rules(project_root)
129
+ markers = resolve_hold_markers(project_root)
130
+ referenced = extract_referenced_issues(project_root)
131
+ now_dt = datetime.fromisoformat(issues_spec["now_iso"].replace("Z", "+00:00"))
132
+ audit_path = project_root / "vbrief" / ".eval" / "candidates.jsonl"
133
+
134
+ counts: dict[str, int] = {"accept": 0, "defer": 0, "archive": 0, "untriaged": 0}
135
+ defer_reasons: dict[str, int] = {}
136
+
137
+ for issue in issues_spec["issues"]:
138
+ n = int(issue["number"])
139
+ gh_issue = {
140
+ "number": n,
141
+ "title": issue["title"],
142
+ "state": issue.get("state", "open"),
143
+ "labels": [{"name": label} for label in issue.get("labels", [])],
144
+ "body": issue.get("body", ""),
145
+ "updated_at": issue.get("updated_at"),
146
+ "created_at": issue.get("created_at"),
147
+ }
148
+ result = classify_issue(
149
+ gh_issue,
150
+ rules=rules,
151
+ hold_markers=markers,
152
+ vbrief_referenced=referenced,
153
+ has_triage_decision=False,
154
+ now=now_dt,
155
+ )
156
+ if result is None:
157
+ counts["untriaged"] += 1
158
+ continue
159
+ if result.action == "archive":
160
+ counts["archive"] += 1
161
+ continue
162
+ entry = {
163
+ "decision_id": str(uuid4()),
164
+ "timestamp": (now_dt + timedelta(seconds=n)).strftime(
165
+ "%Y-%m-%dT%H:%M:%SZ"
166
+ ),
167
+ "repo": FIXTURE_REPO,
168
+ "issue_number": n,
169
+ "decision": result.action,
170
+ "actor": "agent:smoketest-classify",
171
+ "reason": result.reason,
172
+ }
173
+ audit_append(entry, path=audit_path)
174
+ counts[result.action] = counts.get(result.action, 0) + 1
175
+ if result.action == "defer":
176
+ defer_reasons[result.reason] = defer_reasons.get(result.reason, 0) + 1
177
+
178
+ expected_counts = {"accept": 1, "defer": 7, "archive": 0, "untriaged": 12}
179
+ if counts != expected_counts:
180
+ raise log.fail(
181
+ stage, name,
182
+ expected=expected_counts,
183
+ actual=counts,
184
+ cause="auto-classify decision counts diverged from fixture spec",
185
+ )
186
+ expected_defer = {
187
+ "hold marker in body": 3,
188
+ "research": 2,
189
+ "dormant; needs AC refresh": 2,
190
+ }
191
+ if defer_reasons != expected_defer:
192
+ raise log.fail(
193
+ stage, name,
194
+ expected=expected_defer,
195
+ actual=defer_reasons,
196
+ cause="defer-reason bucket counts diverged",
197
+ )
198
+ log.passed(stage, name, detail=f"counts={counts} defer_reasons={defer_reasons}")
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Stage 2: audit decision counts
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def stage_audit_counts(project_root: Path, log: AssertLog) -> None:
207
+ stage = 2
208
+ name = "audit decision counts"
209
+ audit_log = project_root / "vbrief" / ".eval" / "candidates.jsonl"
210
+ proc = run_script(
211
+ "triage_queue.py",
212
+ "audit",
213
+ "--project-root", str(project_root),
214
+ "--repo", FIXTURE_REPO,
215
+ "--audit-log", str(audit_log),
216
+ "--format=json",
217
+ project_root=project_root,
218
+ )
219
+ if proc.returncode != 0:
220
+ raise log.fail(
221
+ stage, name,
222
+ expected="exit 0",
223
+ actual=f"exit {proc.returncode}",
224
+ cause="triage_queue.py audit failed: " + proc.stderr.strip()[:200],
225
+ )
226
+ try:
227
+ payload = json.loads(proc.stdout)
228
+ except json.JSONDecodeError as exc:
229
+ raise log.fail(
230
+ stage, name,
231
+ expected="JSON envelope",
232
+ actual=proc.stdout[:200],
233
+ cause=f"JSON decode error: {exc}",
234
+ ) from exc
235
+
236
+ entries = payload.get("entries") if isinstance(payload, dict) else None
237
+ if not isinstance(entries, list):
238
+ raise log.fail(
239
+ stage, name,
240
+ expected="dict with entries[]",
241
+ actual=type(payload).__name__,
242
+ cause="audit JSON envelope shape unexpected",
243
+ )
244
+
245
+ by_decision: dict[str, int] = {}
246
+ for entry in entries:
247
+ if isinstance(entry, dict):
248
+ decision = entry.get("decision")
249
+ if isinstance(decision, str):
250
+ by_decision[decision] = by_decision.get(decision, 0) + 1
251
+
252
+ expected = {"accept": 1, "defer": 7}
253
+ actual_subset = {k: by_decision.get(k, 0) for k in expected}
254
+ if actual_subset != expected:
255
+ raise log.fail(
256
+ stage, name,
257
+ expected=expected,
258
+ actual=actual_subset,
259
+ cause="audit-log decision counts diverged from stage-1 writes",
260
+ )
261
+ log.passed(
262
+ stage, name, detail=f"entries={len(entries)} by_decision={by_decision}"
263
+ )
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Stage 3: queue determinism + untriaged visibility
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ def stage_queue_determinism(project_root: Path, log: AssertLog) -> None:
272
+ stage = 3
273
+ name = "queue ranking determinism"
274
+
275
+ audit_log = project_root / "vbrief" / ".eval" / "candidates.jsonl"
276
+
277
+ def _run_queue() -> str:
278
+ proc = run_script(
279
+ "triage_queue.py",
280
+ "queue",
281
+ "--project-root", str(project_root),
282
+ "--repo", FIXTURE_REPO,
283
+ "--audit-log", str(audit_log),
284
+ "--limit", "20",
285
+ project_root=project_root,
286
+ )
287
+ if proc.returncode != 0:
288
+ raise log.fail(
289
+ stage, name,
290
+ expected="exit 0",
291
+ actual=f"exit {proc.returncode}",
292
+ cause="triage_queue.py queue failed: " + proc.stderr.strip()[:200],
293
+ )
294
+ return proc.stdout
295
+
296
+ out1 = _run_queue()
297
+ out2 = _run_queue()
298
+ if out1 != out2:
299
+ raise log.fail(
300
+ stage, name,
301
+ expected="identical stdout across two runs",
302
+ actual="stdout diverged on second run",
303
+ cause="ranking non-deterministic",
304
+ )
305
+
306
+ expected_untriaged = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
307
+ missing = sorted(n for n in expected_untriaged if f"#{n}" not in out1)
308
+ if missing:
309
+ raise log.fail(
310
+ stage, name,
311
+ expected="all 12 untriaged numbers visible",
312
+ actual=f"missing: {missing}",
313
+ cause="queue rendering dropped untriaged rows",
314
+ )
315
+ log.passed(stage, name, detail=f"chars={len(out1)} stable across runs")
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # Stage 4: defer with resume-on
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ def stage_defer_resume_on(project_root: Path, log: AssertLog) -> str:
324
+ """Defer issue #5 with a past-date resume-on; assert the audit entry.
325
+
326
+ Runs in-process (not via the CLI subprocess) because ``triage_actions.defer``
327
+ writes through ``candidates_log.append(entry)`` without a path override,
328
+ which would resolve to ``candidates_log.DEFAULT_LOG_PATH`` -- the deft
329
+ framework's own audit log, not the smoketest's tmpdir. The smoketest
330
+ builds the same audit entry that ``triage_actions.defer`` would build
331
+ (via ``triage_actions._build_entry``) and appends it to the tmpdir's
332
+ candidates.jsonl via the explicit ``path=`` override. Hermetic, exercises
333
+ the same entry shape, and avoids leaking writes into the framework tree.
334
+ """
335
+ stage = 4
336
+ name = "defer with resume-on"
337
+
338
+ import triage_actions # noqa: PLC0415
339
+ from candidates_log import append as audit_append # noqa: PLC0415
340
+
341
+ audit_path = project_root / "vbrief" / ".eval" / "candidates.jsonl"
342
+ entry = triage_actions._build_entry(
343
+ "defer",
344
+ 5,
345
+ FIXTURE_REPO,
346
+ actor="agent:smoketest",
347
+ reason="smoketest defer w/ resume-on",
348
+ resume_on="date:>=2020-01-01",
349
+ )
350
+ audit_append(entry, path=audit_path)
351
+ decision_id: str | None = None
352
+ resume_on: str | None = None
353
+ for line in audit_path.read_text(encoding="utf-8").splitlines():
354
+ try:
355
+ entry = json.loads(line)
356
+ except json.JSONDecodeError:
357
+ continue
358
+ if (
359
+ entry.get("issue_number") == 5
360
+ and entry.get("decision") == "defer"
361
+ and entry.get("actor") == "agent:smoketest"
362
+ ):
363
+ decision_id = entry.get("decision_id")
364
+ resume_on = entry.get("resume_on")
365
+
366
+ if decision_id is None:
367
+ raise log.fail(
368
+ stage, name,
369
+ expected="defer audit entry for issue 5",
370
+ actual="no smoketest defer entry",
371
+ cause="triage_actions.py defer did not write the audit entry",
372
+ )
373
+ if resume_on != "date:>=2020-01-01":
374
+ raise log.fail(
375
+ stage, name,
376
+ expected="resume_on=date:>=2020-01-01",
377
+ actual=str(resume_on),
378
+ cause="resume_on field absent or mismatched",
379
+ )
380
+ log.passed(stage, name, detail=f"decision_id={decision_id}")
381
+ return decision_id
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Stage 5: evaluate-resume marker
386
+ # ---------------------------------------------------------------------------
387
+
388
+
389
+ def stage_evaluate_resume(
390
+ project_root: Path, prior_defer_id: str, log: AssertLog
391
+ ) -> None:
392
+ stage = 5
393
+ name = "evaluate-resume marker"
394
+ audit_log = project_root / "vbrief" / ".eval" / "candidates.jsonl"
395
+ proc = run_script(
396
+ "triage_queue.py",
397
+ "audit",
398
+ "--project-root", str(project_root),
399
+ "--repo", FIXTURE_REPO,
400
+ "--audit-log", str(audit_log),
401
+ "--evaluate-resume",
402
+ "--format=json",
403
+ project_root=project_root,
404
+ )
405
+ if proc.returncode != 0:
406
+ raise log.fail(
407
+ stage, name,
408
+ expected="exit 0",
409
+ actual=f"exit {proc.returncode}",
410
+ cause="audit --evaluate-resume failed: " + proc.stderr.strip()[:200],
411
+ )
412
+
413
+ audit_path = project_root / "vbrief" / ".eval" / "candidates.jsonl"
414
+ found: dict[str, Any] | None = None
415
+ for line in audit_path.read_text(encoding="utf-8").splitlines():
416
+ try:
417
+ entry = json.loads(line)
418
+ except json.JSONDecodeError:
419
+ continue
420
+ if (
421
+ entry.get("decision") == "resume-eligible"
422
+ and entry.get("prior_decision_id") == prior_defer_id
423
+ ):
424
+ found = entry
425
+ break
426
+ if found is None:
427
+ raise log.fail(
428
+ stage, name,
429
+ expected=f"resume-eligible entry referencing {prior_defer_id}",
430
+ actual="no resume-eligible entry written",
431
+ cause="evaluate-resume did not fire on date:>=2020-01-01",
432
+ )
433
+ log.passed(stage, name, detail=f"decision_id={found.get('decision_id')}")
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Stage 6: scope:promote (D18 fallback)
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def stage_scope_promote(project_root: Path, log: AssertLog) -> Path:
442
+ """Promote ``vbrief/proposed/test-1.vbrief.json`` to pending/.
443
+
444
+ NOTE: D18 / #1136 (``scope:promote --from-issue=<N>``) is OPEN-but-
445
+ not-implemented at this commit. The smoketest uses the existing
446
+ ``scope:promote <file>`` form per the orchestrator's fallback note;
447
+ see the PR body for the future-integration link.
448
+ """
449
+ stage = 6
450
+ name = "scope:promote (D18 fallback)"
451
+ proposed = project_root / "vbrief" / "proposed" / "test-1.vbrief.json"
452
+ if not proposed.is_file():
453
+ raise log.fail(
454
+ stage, name,
455
+ expected="vbrief/proposed/test-1.vbrief.json",
456
+ actual="missing",
457
+ cause="fixture copy did not place test-1.vbrief.json",
458
+ )
459
+ proc = run_script(
460
+ "scope_lifecycle.py",
461
+ "promote",
462
+ str(proposed),
463
+ "--project-root", str(project_root),
464
+ "--force", # framework worktrees inherit a 60-vBRIEF WIP overage
465
+ project_root=project_root,
466
+ )
467
+ if proc.returncode != 0:
468
+ raise log.fail(
469
+ stage, name,
470
+ expected="exit 0",
471
+ actual=f"exit {proc.returncode}",
472
+ cause="scope_lifecycle.py promote failed: " + proc.stderr.strip()[:200],
473
+ )
474
+ pending = project_root / "vbrief" / "pending" / "test-1.vbrief.json"
475
+ if not pending.is_file():
476
+ raise log.fail(
477
+ stage, name,
478
+ expected="vbrief/pending/test-1.vbrief.json",
479
+ actual="file not in pending/",
480
+ cause="scope:promote did not move the file",
481
+ )
482
+ data = json.loads(pending.read_text(encoding="utf-8"))
483
+ if data.get("plan", {}).get("status") != "pending":
484
+ raise log.fail(
485
+ stage, name,
486
+ expected="plan.status=pending",
487
+ actual=str(data.get("plan", {}).get("status")),
488
+ cause="plan.status not flipped to pending",
489
+ )
490
+ log.passed(stage, name, detail="proposed -> pending OK")
491
+ return pending
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # Stage 7: scope:demote single-file
496
+ # ---------------------------------------------------------------------------
497
+
498
+
499
+ def stage_scope_demote(project_root: Path, pending: Path, log: AssertLog) -> None:
500
+ stage = 7
501
+ name = "scope:demote single-file"
502
+ reason = "smoketest single demote"
503
+ proc = run_script(
504
+ "scope_demote.py",
505
+ str(pending),
506
+ "--reason", reason,
507
+ "--actor", "agent:smoketest",
508
+ "--project-root", str(project_root),
509
+ project_root=project_root,
510
+ )
511
+ if proc.returncode != 0:
512
+ raise log.fail(
513
+ stage, name,
514
+ expected="exit 0",
515
+ actual=f"exit {proc.returncode}",
516
+ cause="scope_demote.py failed: " + proc.stderr.strip()[:200],
517
+ )
518
+ proposed = project_root / "vbrief" / "proposed" / "test-1.vbrief.json"
519
+ if not proposed.is_file():
520
+ raise log.fail(
521
+ stage, name,
522
+ expected="file back in proposed/",
523
+ actual="missing",
524
+ cause="scope:demote did not move file back",
525
+ )
526
+ log_path = project_root / "vbrief" / ".eval" / "scope-lifecycle.jsonl"
527
+ last_demote: dict[str, Any] | None = None
528
+ if log_path.is_file():
529
+ for line in log_path.read_text(encoding="utf-8").splitlines():
530
+ try:
531
+ entry = json.loads(line)
532
+ except json.JSONDecodeError:
533
+ continue
534
+ if (
535
+ entry.get("action") == "demote"
536
+ and entry.get("actor") == "agent:smoketest"
537
+ ):
538
+ last_demote = entry
539
+ if last_demote is None:
540
+ raise log.fail(
541
+ stage, name,
542
+ expected="demote audit entry from smoketest",
543
+ actual="no matching entry",
544
+ cause="scope:demote audit entry missing",
545
+ )
546
+ meta = last_demote.get("demote_meta") or {}
547
+ if meta.get("demoted_from") != "pending":
548
+ raise log.fail(
549
+ stage, name,
550
+ expected="demote_meta.demoted_from=pending",
551
+ actual=meta.get("demoted_from"),
552
+ cause="demote_meta.demoted_from mismatched",
553
+ )
554
+ if meta.get("demote_reason") != reason:
555
+ raise log.fail(
556
+ stage, name,
557
+ expected=f"demote_meta.demote_reason={reason!r}",
558
+ actual=meta.get("demote_reason"),
559
+ cause="demote_meta.demote_reason mismatched",
560
+ )
561
+ log.passed(
562
+ stage, name,
563
+ detail=(
564
+ f"demoted_from={meta.get('demoted_from')} "
565
+ f"days_in_pending={meta.get('days_in_pending')}"
566
+ ),
567
+ )
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # Stage 8: scope:undo (graceful skip when D15 / #1134 absent)
572
+ # ---------------------------------------------------------------------------
573
+
574
+
575
+ def stage_scope_undo(project_root: Path, log: AssertLog) -> None:
576
+ stage = 8
577
+ name = "scope:undo idempotency"
578
+ candidate = _SCRIPTS_DIR / "scope_undo.py"
579
+ if not candidate.is_file():
580
+ sys.stderr.write(
581
+ "[triage:smoketest] D15 / #1134 (scope:undo) has not landed yet; "
582
+ "skipping stage 8 with informational stderr per the orchestrator's "
583
+ "graceful-skip rule.\n"
584
+ )
585
+ log.skipped(stage, name, reason="D15 / #1134 not yet merged")
586
+ return
587
+ proc = run_script( # pragma: no cover -- exercised once D15 lands
588
+ "scope_undo.py",
589
+ "--latest",
590
+ "--project-root", str(project_root),
591
+ project_root=project_root,
592
+ )
593
+ if proc.returncode != 0:
594
+ raise log.fail(
595
+ stage, name,
596
+ expected="exit 0",
597
+ actual=f"exit {proc.returncode}",
598
+ cause="scope_undo.py failed: " + proc.stderr.strip()[:200],
599
+ )
600
+ log.passed(stage, name, detail="undo recorded")
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Stage 9: triage:summary bounded output
605
+ # ---------------------------------------------------------------------------
606
+
607
+
608
+ def stage_triage_summary(project_root: Path, log: AssertLog) -> None:
609
+ stage = 9
610
+ name = "triage:summary bounded output"
611
+ proc = run_script(
612
+ "triage_summary.py",
613
+ "--project-root", str(project_root),
614
+ "--no-history",
615
+ project_root=project_root,
616
+ )
617
+ if proc.returncode != 0:
618
+ raise log.fail(
619
+ stage, name,
620
+ expected="exit 0",
621
+ actual=f"exit {proc.returncode}",
622
+ cause="triage_summary.py failed: " + proc.stderr.strip()[:200],
623
+ )
624
+ out = proc.stdout.strip()
625
+ lines = [line for line in out.splitlines() if line.strip()]
626
+ if not lines:
627
+ raise log.fail(
628
+ stage, name,
629
+ expected="at least the bounded headline",
630
+ actual="no output",
631
+ cause="triage:summary emitted nothing",
632
+ )
633
+ # #1122 bounds the HEADLINE (the first physical line). #1270
634
+ # ([triage:scope]) and #1468 ([triage:reconcile]) add intentional
635
+ # informational lines BELOW the headline; the fixture has a proposed
636
+ # vBRIEF (test-1, issue #1) with no audit decision, which is a
637
+ # legitimate reconcile divergence, so the summary correctly emits a
638
+ # second line. Validate the bounded headline and assert any extra
639
+ # lines are ONLY the recognized informational divergence/hint lines
640
+ # (genuine multi-line garbage still fails).
641
+ headline = lines[0]
642
+ extra_lines = lines[1:]
643
+ unexpected = [
644
+ ln
645
+ for ln in extra_lines
646
+ if not ln.startswith(("[triage:scope]", "[triage:reconcile]"))
647
+ ]
648
+ if unexpected:
649
+ raise log.fail(
650
+ stage, name,
651
+ expected=(
652
+ "only [triage:scope] / [triage:reconcile] informational "
653
+ "lines below the headline"
654
+ ),
655
+ actual=f"{len(unexpected)} unexpected extra line(s): {unexpected[0][:60]!r}",
656
+ cause="unexpected multi-line output",
657
+ )
658
+ if len(headline) > SUMMARY_MAX_CHARS:
659
+ raise log.fail(
660
+ stage, name,
661
+ expected=f"<= {SUMMARY_MAX_CHARS} chars",
662
+ actual=f"{len(headline)} chars",
663
+ cause="exceeded MAX_LINE_CHARS budget",
664
+ )
665
+ if WARN_GLYPH in headline:
666
+ raise log.fail(
667
+ stage, name,
668
+ expected="no warning glyph (WIP under cap)",
669
+ actual="warning glyph U+26A0 present",
670
+ cause="emitted U+26A0 against under-cap fixture",
671
+ )
672
+ log.passed(
673
+ stage, name, detail=f"chars={len(headline)} extra_lines={len(extra_lines)}"
674
+ )