@deftai/directive-content 0.55.2 → 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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,692 @@
1
+ #!/usr/bin/env python3
2
+ """slice_record_existing.py -- ``task slice:record-existing`` driver (#1147 / N7 of #1119).
3
+
4
+ Retrofit a ``vbrief/.eval/slices.jsonl`` entry for a cohort that the
5
+ framework did NOT produce (hand-filed umbrella + manually-created
6
+ children -- the dominant historical pattern in deftai/directive,
7
+ including #1119 itself). D13 (#1132) writes ``slices.jsonl`` only when
8
+ slicing skills fire (``deft-directive-gh-slice``,
9
+ ``deft-directive-gh-arch``, refinement's slice phase); this verb is
10
+ the canonical backfill path for everything else.
11
+
12
+ Two operating modes
13
+ -------------------
14
+
15
+ 1. ``slice:record-existing`` (default sub-command):
16
+
17
+ slice_record_existing.py record-existing \
18
+ --umbrella=<N> --children=<N>,<M>,... \
19
+ [--wave-1=<N>,...] [--wave-N=...] \
20
+ [--actor=manual:operator] \
21
+ [--expected-close-signal=all-children-merged] \
22
+ [--sliced-at=<iso>] \
23
+ [--notes=<text>] \
24
+ [--dry-run] [--force] \
25
+ [--repo OWNER/NAME] [--project-root PATH]
26
+
27
+ Default ``actor`` is ``manual:operator`` (vs the skill-emitted
28
+ ``skill:gh-slice``). Wave assignment: a child appearing in
29
+ ``--wave-N`` is assigned to that wave; otherwise wave 1.
30
+
31
+ 2. ``slice:list`` companion sub-command:
32
+
33
+ slice_record_existing.py list [--repo OWNER/NAME] [--project-root PATH]
34
+
35
+ Prints every recorded slice with umbrella + child count + actor +
36
+ sliced_at timestamp. Useful for verifying the backfill landed
37
+ alongside skill-produced entries.
38
+
39
+ Validation
40
+ ----------
41
+
42
+ * Umbrella + each child issue number must exist (probed via
43
+ ``scm.call("github-issue", "issue", ["view", str(N), ...])`` per N5
44
+ / #1145). The probe is skipped only when ``--skip-validation`` is
45
+ passed (an escape hatch for cohorts whose issues live in a private
46
+ mirror -- documented but not advertised). ``--dry-run`` alone does
47
+ NOT bypass the probe; validation still fires so the preview reflects
48
+ the actual reachability of each issue (#1230 -- Greptile P2).
49
+ * Idempotency: a record with the same ``umbrella`` AND the same
50
+ ``children`` set (compared by ``{n}`` set, order-insensitive) is
51
+ treated as already-present -- the verb is a no-op with
52
+ informational stderr and exits 0. ``--force`` bypasses this check
53
+ so an umbrella can carry multiple slice records (legitimate when
54
+ slicing happens in multiple sessions).
55
+
56
+ Exit codes
57
+ ----------
58
+
59
+ * 0 -- record written, dry-run preview, or idempotent no-op.
60
+ * 1 -- validation failure (missing umbrella / child, scm error,
61
+ invalid record schema, malformed flags).
62
+ * 2 -- usage error (missing required flag, unknown sub-command,
63
+ undetectable project root / repo).
64
+
65
+ Refs: #1119 (umbrella), #1132 (D13 writer + schema this consumes),
66
+ #1144 (N4 ``vbrief/.eval/`` governance -- ``slices.jsonl`` is
67
+ committed, not gitignored), #1145 (N5 ``scm.call`` shim).
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import argparse
73
+ import json
74
+ import re
75
+ import subprocess
76
+ import sys
77
+ from collections.abc import Sequence
78
+ from pathlib import Path
79
+
80
+ # Make sibling helpers importable when run as __main__.
81
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
82
+
83
+ import scm # noqa: E402
84
+ import slice_record # noqa: E402
85
+ from _project_context import ( # noqa: E402
86
+ resolve_project_repo,
87
+ resolve_project_root,
88
+ )
89
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
90
+
91
+ reconfigure_stdio()
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Constants
96
+ # ---------------------------------------------------------------------------
97
+
98
+ DEFAULT_ACTOR = "manual:operator"
99
+ DEFAULT_EXPECTED_CLOSE_SIGNAL = "all-children-merged"
100
+ DEFAULT_ROLE = "manual"
101
+
102
+ _WAVE_FLAG_RE = re.compile(r"^--wave-(\d+)(?:=(.*))?$")
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Helpers
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def _parse_children_csv(value: str) -> list[int]:
111
+ """Parse a comma-separated list of issue numbers; raise on malformed input."""
112
+ if not value:
113
+ raise ValueError("expected at least one child issue number")
114
+ out: list[int] = []
115
+ seen: set[int] = set()
116
+ for part in value.split(","):
117
+ token = part.strip()
118
+ if not token:
119
+ continue
120
+ try:
121
+ n = int(token)
122
+ except ValueError as exc:
123
+ raise ValueError(
124
+ f"invalid child issue number {token!r} (must be a positive int)"
125
+ ) from exc
126
+ if n < 1:
127
+ raise ValueError(f"invalid child issue number {n} (must be a positive int)")
128
+ if n in seen:
129
+ raise ValueError(f"duplicate child issue number {n}")
130
+ seen.add(n)
131
+ out.append(n)
132
+ if not out:
133
+ raise ValueError("expected at least one child issue number")
134
+ return out
135
+
136
+
137
+ def _consume_wave_flags(raw_args: list[str]) -> tuple[dict[int, list[int]], list[str]]:
138
+ """Extract every ``--wave-N=<csv>`` / ``--wave-N <csv>`` occurrence.
139
+
140
+ Returns ``(wave_map, remaining_args)`` where ``wave_map`` is keyed by
141
+ the wave number (1, 2, ...) and carries the list of child numbers
142
+ assigned to that wave. ``remaining_args`` carries every token argparse
143
+ will then parse with the static flag list. ``argparse`` cannot model
144
+ a dynamic flag prefix on its own, so this small pre-pass owns the
145
+ ``--wave-N`` shape (mirrors the pattern in scripts/scm.py's
146
+ ``_extract_value_flag``).
147
+ """
148
+ wave_map: dict[int, list[int]] = {}
149
+ remaining: list[str] = []
150
+ i = 0
151
+ while i < len(raw_args):
152
+ token = raw_args[i]
153
+ match = _WAVE_FLAG_RE.match(token)
154
+ if not match:
155
+ remaining.append(token)
156
+ i += 1
157
+ continue
158
+ wave_n = int(match.group(1))
159
+ if wave_n < 1:
160
+ raise ValueError(f"invalid wave number in {token!r} (must be >= 1)")
161
+ value: str | None
162
+ if match.group(2) is not None:
163
+ value = match.group(2)
164
+ i += 1
165
+ elif i + 1 < len(raw_args):
166
+ value = raw_args[i + 1]
167
+ i += 2
168
+ else:
169
+ raise ValueError(f"missing value for {token!r}")
170
+ children = _parse_children_csv(value)
171
+ bucket = wave_map.setdefault(wave_n, [])
172
+ for n in children:
173
+ if n in bucket:
174
+ # Tolerate intra-wave duplicates (cheap), surface
175
+ # cross-wave duplicates below.
176
+ continue
177
+ bucket.append(n)
178
+ # Cross-wave duplicates: a child cannot be in two waves.
179
+ placement: dict[int, int] = {}
180
+ for wave_n, members in wave_map.items():
181
+ for n in members:
182
+ if n in placement and placement[n] != wave_n:
183
+ raise ValueError(
184
+ f"child {n} appears in both --wave-{placement[n]} "
185
+ f"and --wave-{wave_n}; each child belongs to one wave"
186
+ )
187
+ placement[n] = wave_n
188
+ return wave_map, remaining
189
+
190
+
191
+ def _repo_slug_to_url(repo: str, n: int) -> str:
192
+ return f"https://github.com/{repo}/issues/{n}"
193
+
194
+
195
+ def _issues_jsonl_path(project_root: Path) -> Path:
196
+ return project_root / "vbrief" / ".eval" / "slices.jsonl"
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Issue existence validation (N5 shim)
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ class IssueValidationError(RuntimeError):
205
+ """Raised when an issue number cannot be validated via the SCM shim."""
206
+
207
+
208
+ def _validate_issue_exists(
209
+ n: int,
210
+ *,
211
+ repo: str,
212
+ scm_module=scm,
213
+ ) -> None:
214
+ """Probe ``gh issue view <N> --repo <repo>`` via the N5 shim.
215
+
216
+ Raises :class:`IssueValidationError` on a non-zero exit. The shim
217
+ itself raises :class:`NotImplementedError` for non-``github-issue``
218
+ sources -- that bubbles up so a consumer on GitLab / Gitea sees
219
+ the deferred abstraction (#445 / #935 Workstream 6) immediately.
220
+ """
221
+ try:
222
+ proc = scm_module.call(
223
+ "github-issue",
224
+ "issue",
225
+ ["view", str(n), "--repo", repo, "--json", "number,url"],
226
+ check=False,
227
+ capture_output=True,
228
+ text=True,
229
+ timeout=30,
230
+ )
231
+ except subprocess.TimeoutExpired as exc:
232
+ raise IssueValidationError(f"timed out validating issue #{n} in {repo}") from exc
233
+ if proc.returncode != 0:
234
+ stderr = (proc.stderr or "").strip() or "(no stderr)"
235
+ raise IssueValidationError(f"issue #{n} in {repo} not found / inaccessible: {stderr}")
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Build + write
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ def _build_children(
244
+ children: list[int],
245
+ wave_map: dict[int, list[int]],
246
+ repo: str,
247
+ ) -> list[dict[str, object]]:
248
+ """Construct the per-child dicts in the slices.jsonl schema shape."""
249
+ wave_for: dict[int, int] = {}
250
+ for wave_n, members in wave_map.items():
251
+ for n in members:
252
+ wave_for[n] = wave_n
253
+ out: list[dict[str, object]] = []
254
+ for n in children:
255
+ out.append(
256
+ {
257
+ "n": n,
258
+ "url": _repo_slug_to_url(repo, n),
259
+ "wave": wave_for.get(n, 1),
260
+ "role": DEFAULT_ROLE,
261
+ }
262
+ )
263
+ return out
264
+
265
+
266
+ def _children_set(record: dict) -> frozenset[int]:
267
+ children = record.get("children")
268
+ if not isinstance(children, list):
269
+ return frozenset()
270
+ out: set[int] = set()
271
+ for child in children:
272
+ if isinstance(child, dict):
273
+ n = child.get("n")
274
+ if isinstance(n, int):
275
+ out.add(n)
276
+ return frozenset(out)
277
+
278
+
279
+ def _find_duplicate(
280
+ umbrella: int,
281
+ children_numbers: list[int],
282
+ *,
283
+ slices_path: Path,
284
+ record_module=slice_record,
285
+ ) -> dict | None:
286
+ """Return the first existing slice record that matches umbrella + child-set, or None."""
287
+ target = frozenset(children_numbers)
288
+ for record in record_module.read_all(path=slices_path):
289
+ if record.get("umbrella") != umbrella:
290
+ continue
291
+ if _children_set(record) == target:
292
+ return record
293
+ return None
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # CLI
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def _build_parser() -> argparse.ArgumentParser:
302
+ parser = argparse.ArgumentParser(
303
+ prog="slice_record_existing.py",
304
+ description=(
305
+ "Retrofit a slices.jsonl entry for a hand-filed cohort "
306
+ "(#1147 / N7 of #1119). Default sub-command is 'record-existing'."
307
+ ),
308
+ )
309
+ sub = parser.add_subparsers(dest="command")
310
+
311
+ record = sub.add_parser(
312
+ "record-existing",
313
+ help="Write a backfill slice record (default sub-command).",
314
+ )
315
+ record.add_argument(
316
+ "--umbrella",
317
+ type=int,
318
+ required=True,
319
+ help="Umbrella issue number.",
320
+ )
321
+ record.add_argument(
322
+ "--children",
323
+ required=True,
324
+ help="Comma-separated child issue numbers (e.g. 1121,1122,1123).",
325
+ )
326
+ record.add_argument(
327
+ "--actor",
328
+ default=DEFAULT_ACTOR,
329
+ help=(
330
+ f"Slicing actor identity (default: {DEFAULT_ACTOR}). "
331
+ "Distinguishes backfill records from skill-emitted records."
332
+ ),
333
+ )
334
+ record.add_argument(
335
+ "--expected-close-signal",
336
+ default=DEFAULT_EXPECTED_CLOSE_SIGNAL,
337
+ help=(
338
+ f"One of all-children-merged|wave-1-merged|manual "
339
+ f"(default: {DEFAULT_EXPECTED_CLOSE_SIGNAL})."
340
+ ),
341
+ )
342
+ record.add_argument(
343
+ "--sliced-at",
344
+ default=None,
345
+ help="ISO-8601 UTC timestamp (e.g. 2026-05-14T17:00:00Z). Defaults to now.",
346
+ )
347
+ record.add_argument(
348
+ "--notes",
349
+ default=None,
350
+ help="Free-text rationale recorded on the slice entry.",
351
+ )
352
+ record.add_argument(
353
+ "--dry-run",
354
+ action="store_true",
355
+ help="Print the proposed entry to stdout without writing.",
356
+ )
357
+ record.add_argument(
358
+ "--force",
359
+ action="store_true",
360
+ help=(
361
+ "Bypass idempotency: write a new record even when an entry "
362
+ "with the same umbrella + child set already exists. "
363
+ "Legitimate when slicing happens in multiple sessions."
364
+ ),
365
+ )
366
+ record.add_argument(
367
+ "--skip-validation",
368
+ action="store_true",
369
+ help=(
370
+ "Skip the scm.call issue-existence probes. Documented escape "
371
+ "hatch for cohorts whose issues live in a private mirror -- "
372
+ "use sparingly."
373
+ ),
374
+ )
375
+ record.add_argument(
376
+ "--repo",
377
+ default=None,
378
+ help=(
379
+ "Consumer GitHub repo (OWNER/NAME). Defaults to "
380
+ "$DEFT_PROJECT_REPO or `git remote get-url origin`."
381
+ ),
382
+ )
383
+ record.add_argument(
384
+ "--project-root",
385
+ default=None,
386
+ help="Consumer project root. Overrides $DEFT_PROJECT_ROOT.",
387
+ )
388
+
389
+ list_cmd = sub.add_parser(
390
+ "list",
391
+ help="List recorded slices with umbrella + child counts + actor.",
392
+ )
393
+ list_cmd.add_argument(
394
+ "--project-root",
395
+ default=None,
396
+ help="Consumer project root. Overrides $DEFT_PROJECT_ROOT.",
397
+ )
398
+ list_cmd.add_argument(
399
+ "--json",
400
+ dest="as_json",
401
+ action="store_true",
402
+ help="Emit the slice records as a JSON array (one object per slice).",
403
+ )
404
+
405
+ return parser
406
+
407
+
408
+ def _resolve_root_and_repo(
409
+ cli_project_root: str | None,
410
+ cli_repo: str | None,
411
+ *,
412
+ require_repo: bool,
413
+ ) -> tuple[Path, str | None, int]:
414
+ """Resolve ``(project_root, repo, error_exit_code)``.
415
+
416
+ On success returns ``(root, repo_or_None, 0)``. On failure prints
417
+ a loud error to stderr and returns ``(Path('.'), None, exit_code)``.
418
+ """
419
+ project_root = resolve_project_root(cli_project_root)
420
+ if project_root is None:
421
+ print(
422
+ "error: cannot determine project root. Pass --project-root PATH, "
423
+ "set $DEFT_PROJECT_ROOT, or run from inside a directory tree that "
424
+ "contains vbrief/ or .git/ (#535).",
425
+ file=sys.stderr,
426
+ )
427
+ return Path("."), None, 2
428
+ if not require_repo:
429
+ return project_root, None, 0
430
+ repo = resolve_project_repo(cli_repo, project_root=project_root)
431
+ if not repo:
432
+ print(
433
+ "error: cannot determine repo slug. Pass --repo OWNER/NAME, "
434
+ "set $DEFT_PROJECT_REPO, or run inside a git checkout with an "
435
+ "origin remote.",
436
+ file=sys.stderr,
437
+ )
438
+ return project_root, None, 2
439
+ return project_root, repo, 0
440
+
441
+
442
+ def _run_record_existing(args: argparse.Namespace, wave_map: dict[int, list[int]]) -> int:
443
+ project_root, repo, exit_code = _resolve_root_and_repo(
444
+ args.project_root, args.repo, require_repo=True
445
+ )
446
+ if exit_code != 0:
447
+ return exit_code
448
+ if repo is None: # pragma: no cover -- guaranteed non-None when require_repo=True
449
+ # Explicit guard so this safety check survives `python -O` (where
450
+ # bare ``assert`` is stripped). See #1230 -- Greptile P2.
451
+ raise RuntimeError(
452
+ "repo is None despite require_repo=True; this is a bug in "
453
+ "_resolve_root_and_repo"
454
+ )
455
+
456
+ try:
457
+ children = _parse_children_csv(args.children)
458
+ except ValueError as exc:
459
+ print(f"error: {exc}", file=sys.stderr)
460
+ return 2
461
+
462
+ # Validate wave members are a subset of the declared children.
463
+ declared = set(children)
464
+ for wave_n, members in wave_map.items():
465
+ for n in members:
466
+ if n not in declared:
467
+ print(
468
+ f"error: --wave-{wave_n} references child #{n} not present in --children",
469
+ file=sys.stderr,
470
+ )
471
+ return 2
472
+
473
+ if args.umbrella in declared:
474
+ print(
475
+ f"error: umbrella #{args.umbrella} cannot also appear in --children",
476
+ file=sys.stderr,
477
+ )
478
+ return 2
479
+
480
+ # Issue-existence validation via scm.call (N5 shim). Skipped under
481
+ # --skip-validation (documented escape hatch) so an operator can
482
+ # backfill cohorts whose issues live in a private mirror or have
483
+ # been deleted post-slice.
484
+ if not args.skip_validation:
485
+ try:
486
+ _validate_issue_exists(args.umbrella, repo=repo)
487
+ for n in children:
488
+ _validate_issue_exists(n, repo=repo)
489
+ except IssueValidationError as exc:
490
+ print(f"error: {exc}", file=sys.stderr)
491
+ return 1
492
+ except NotImplementedError as exc:
493
+ print(f"error: {exc}", file=sys.stderr)
494
+ return 1
495
+
496
+ # Idempotency: refuse to duplicate a record with the same umbrella +
497
+ # child set unless --force. Pre-lock peek is a fast-path optimisation
498
+ # for the common no-concurrency case (no file IO under the lock when
499
+ # an obvious duplicate exists); the authoritative re-check fires
500
+ # under the file lock below so two concurrent invocations cannot
501
+ # both observe "no duplicate" and both append (P1 TOCTOU per #1231).
502
+ slices_path = _issues_jsonl_path(project_root)
503
+ duplicate = _find_duplicate(args.umbrella, children, slices_path=slices_path)
504
+ if duplicate is not None and not args.force:
505
+ print(
506
+ f"slice:record-existing: umbrella #{args.umbrella} already has a "
507
+ f"matching record (slice_id={duplicate.get('slice_id')}, "
508
+ f"actor={duplicate.get('actor')}). Re-run with --force to write "
509
+ "a second record.",
510
+ file=sys.stderr,
511
+ )
512
+ return 0
513
+
514
+ child_dicts = _build_children(children, wave_map, repo)
515
+
516
+ # Dry-run path: build the proposed record without writing. No lock
517
+ # needed -- dry-run is read-only and does not race against itself.
518
+ if args.dry_run:
519
+ proposed = {
520
+ "slice_id": "<dry-run>",
521
+ "umbrella": args.umbrella,
522
+ "umbrella_url": _repo_slug_to_url(repo, args.umbrella),
523
+ "sliced_at": args.sliced_at or slice_record.now_iso(),
524
+ "actor": args.actor,
525
+ "children": child_dicts,
526
+ "expected_close_signal": args.expected_close_signal,
527
+ }
528
+ if args.notes is not None:
529
+ proposed["notes"] = args.notes
530
+ print(json.dumps(proposed, sort_keys=True, ensure_ascii=False, indent=2))
531
+ wave_summary = _summarise_waves(wave_map, len(children))
532
+ print(
533
+ f"DRY-RUN: would write slices.jsonl entry for umbrella "
534
+ f"#{args.umbrella} ({len(children)} children, {wave_summary}).",
535
+ file=sys.stderr,
536
+ )
537
+ return 0
538
+
539
+ # Atomic idempotency (#1231 / P1 TOCTOU fix): the duplicate check
540
+ # AND the append must run under one critical section so two
541
+ # concurrent invocations of `task slice:record-existing` (neither
542
+ # passing --force) cannot both observe "no duplicate" between the
543
+ # check and the append. Acquire the sidecar lock that already
544
+ # serialises every slice_record.write_slice call, run a second
545
+ # _find_duplicate inside the lock (this is the authoritative pass
546
+ # -- the pre-lock peek above is only a fast path for the common
547
+ # uncontended case), and then call write_slice_unlocked so we do
548
+ # not deadlock on re-entry into the same lock.
549
+ record: dict = {
550
+ "slice_id": slice_record.new_slice_id(),
551
+ "umbrella": args.umbrella,
552
+ "umbrella_url": _repo_slug_to_url(repo, args.umbrella),
553
+ "sliced_at": args.sliced_at or slice_record.now_iso(),
554
+ "actor": args.actor,
555
+ "children": child_dicts,
556
+ "expected_close_signal": args.expected_close_signal,
557
+ }
558
+ if args.notes is not None:
559
+ record["notes"] = args.notes
560
+
561
+ slices_path.parent.mkdir(parents=True, exist_ok=True)
562
+ try:
563
+ with slice_record.append_lock(slices_path):
564
+ authoritative_dup = _find_duplicate(
565
+ args.umbrella, children, slices_path=slices_path
566
+ )
567
+ if authoritative_dup is not None and not args.force:
568
+ print(
569
+ f"slice:record-existing: umbrella #{args.umbrella} "
570
+ f"already has a matching record (slice_id="
571
+ f"{authoritative_dup.get('slice_id')}, actor="
572
+ f"{authoritative_dup.get('actor')}). Re-run with "
573
+ "--force to write a second record.",
574
+ file=sys.stderr,
575
+ )
576
+ return 0
577
+ slice_id = slice_record.write_slice_unlocked(
578
+ record=record, path=slices_path
579
+ )
580
+ except slice_record.SliceRecordError as exc:
581
+ print(f"error: invalid record -- {exc}", file=sys.stderr)
582
+ return 1
583
+
584
+ wave_summary = _summarise_waves(wave_map, len(children))
585
+ print(
586
+ f"Wrote vbrief/.eval/slices.jsonl entry for umbrella "
587
+ f"#{args.umbrella} ({len(children)} children, {wave_summary}). "
588
+ f"slice_id={slice_id}"
589
+ )
590
+ return 0
591
+
592
+
593
+ def _summarise_waves(wave_map: dict[int, list[int]], total_children: int) -> str:
594
+ """Render the operator-facing wave-distribution summary.
595
+
596
+ Children declared in ``--children`` but absent from every ``--wave-N``
597
+ flag fall through to wave 1 (the default). Per #1230 -- Greptile P2,
598
+ the unassigned-default count is MERGED into the wave-1 entry rather
599
+ than rendered as a second ``wave-1=N (default)`` segment, so a caller
600
+ passing ``--wave-1=2 --wave-2=3`` with one unassigned child sees
601
+ ``"2 wave(s): wave-1=2, wave-2=1"`` rather than the pre-fix
602
+ ``"3 wave(s): wave-1=1, wave-2=1, wave-1=1 (default)"``.
603
+ """
604
+ if not wave_map:
605
+ return f"{total_children} in wave 1 (default)"
606
+ placed_by_wave: dict[int, int] = {
607
+ wave_n: len(members) for wave_n, members in wave_map.items()
608
+ }
609
+ placed_total = sum(placed_by_wave.values())
610
+ unassigned = total_children - placed_total
611
+ if unassigned > 0:
612
+ placed_by_wave[1] = placed_by_wave.get(1, 0) + unassigned
613
+ parts = [f"wave-{wave_n}={placed_by_wave[wave_n]}" for wave_n in sorted(placed_by_wave)]
614
+ return f"{len(parts)} wave(s): " + ", ".join(parts)
615
+
616
+
617
+ def _run_list(args: argparse.Namespace) -> int:
618
+ project_root, _repo, exit_code = _resolve_root_and_repo(
619
+ args.project_root, None, require_repo=False
620
+ )
621
+ if exit_code != 0:
622
+ return exit_code
623
+
624
+ slices_path = _issues_jsonl_path(project_root)
625
+ records = slice_record.read_all(path=slices_path)
626
+
627
+ if args.as_json:
628
+ print(json.dumps(records, ensure_ascii=False, indent=2, sort_keys=True))
629
+ return 0
630
+
631
+ if not records:
632
+ print("slice:list: no records found in vbrief/.eval/slices.jsonl (file absent or empty).")
633
+ return 0
634
+
635
+ print(f"slice:list: {len(records)} record(s) in vbrief/.eval/slices.jsonl")
636
+ for record in records:
637
+ umbrella = record.get("umbrella", "?")
638
+ actor = record.get("actor", "?")
639
+ sliced_at = record.get("sliced_at", "?")
640
+ slice_id = record.get("slice_id", "?")
641
+ children = record.get("children")
642
+ child_count = len(children) if isinstance(children, list) else 0
643
+ signal = record.get("expected_close_signal", "?")
644
+ notes = record.get("notes")
645
+ line = (
646
+ f" - umbrella=#{umbrella} children={child_count} "
647
+ f"actor={actor} sliced_at={sliced_at} "
648
+ f"signal={signal} slice_id={slice_id}"
649
+ )
650
+ if notes:
651
+ line += f" notes={notes!r}"
652
+ print(line)
653
+ return 0
654
+
655
+
656
+ def main(argv: Sequence[str] | None = None) -> int:
657
+ raw = list(sys.argv[1:] if argv is None else argv)
658
+ # No sub-command and at least one non-flag arg is unusual; default
659
+ # to `record-existing` to match the documented user-facing surface
660
+ # (``task slice:record-existing --umbrella=N ...`` forwards the
661
+ # remaining flags to this script with no positional sub-command).
662
+ if raw and raw[0] not in {"record-existing", "list", "-h", "--help"}:
663
+ raw = ["record-existing", *raw]
664
+ elif not raw:
665
+ raw = ["record-existing"]
666
+
667
+ # Pre-pass: strip out --wave-N flags before argparse sees them
668
+ # (argparse cannot model a dynamic flag prefix). Only relevant for
669
+ # the `record-existing` sub-command; the `list` sub-command has no
670
+ # --wave-N surface so the pre-pass is a no-op there.
671
+ if raw and raw[0] == "record-existing":
672
+ try:
673
+ wave_map, raw = _consume_wave_flags(raw)
674
+ except ValueError as exc:
675
+ print(f"error: {exc}", file=sys.stderr)
676
+ return 2
677
+ else:
678
+ wave_map = {}
679
+
680
+ parser = _build_parser()
681
+ try:
682
+ args = parser.parse_args(raw)
683
+ except SystemExit as exc:
684
+ return int(exc.code) if isinstance(exc.code, int) else 2
685
+
686
+ if args.command == "list":
687
+ return _run_list(args)
688
+ return _run_record_existing(args, wave_map)
689
+
690
+
691
+ if __name__ == "__main__":
692
+ raise SystemExit(main())