@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,904 +0,0 @@
1
- # ruff: noqa: E501 -- the canonical README literal at the bottom of this
2
- # file contains markdown table rows that intentionally run past the 100-char
3
- # ceiling so the rendered README is byte-identical to the on-disk
4
- # `vbrief/.eval/README.md`. Splitting the table cells across lines breaks
5
- # Markdown rendering. The rest of the module respects the project ceiling.
6
- """_triage_bootstrap_gitignore.py -- gitignore-ensure + audit-log seed helpers.
7
-
8
- Extracted from :mod:`triage_bootstrap` under #952 to keep the parent
9
- module under the 1000-line MUST limit from ``coding/coding.md``. The
10
- helpers are pure (no module-level state) and operate on the consumer
11
- project's ``.gitignore``, ``.gitattributes``, Deft runtime sentinel
12
- paths, and ``vbrief/.eval/`` scratch directory only; nothing here
13
- touches the cache or scope vBRIEF state.
14
-
15
- Public surface (stable for :mod:`triage_bootstrap` re-exports):
16
-
17
- - :data:`GITIGNORE_LINE` -- canonical ``.deft-cache/`` line.
18
- - :data:`GITIGNORE_DEFT_RUNTIME_SENTINELS` -- canonical selective
19
- ``.deft`` runtime sentinel lines (``ritual-state.json`` /
20
- ``last-session.json``). Single source of truth mirrored by the
21
- installer and imported by the relocator (#1609).
22
- - :data:`GITIGNORE_EVAL_ENTRIES` -- canonical selective per-file lines
23
- for the #1144 hybrid policy (``candidates.jsonl`` /
24
- ``summary-history.jsonl`` / ``scope-lifecycle.jsonl`` /
25
- ``decompositions/`` / ``doctor-state.json``). Single source of truth
26
- the installer (``cmd/deft-install/setup.go``) mirrors and the relocator
27
- (``scripts/relocate.py``) imports (#1464).
28
- - :data:`FORBIDDEN_BLANKET_EVAL_LINES` -- canonical forbidden blanket
29
- lines (``vbrief/.eval/`` / ``vbrief/.eval``) shared with the installer
30
- and relocator deposit rails so all three agree on what to heal (#1464).
31
- - :func:`strip_gitignore_inline_comment` -- public inline-comment strip
32
- reused by the installer/relocator heal rails (#1464).
33
- - :data:`GITATTRIBUTES_EVAL_RULE` -- canonical
34
- ``vbrief/.eval/*.jsonl merge=union`` line for #1144.
35
- - :func:`step_ensure_gitignore_entry` -- bootstrap step 3.
36
- - :func:`step_ensure_gitignore_eval_entries` -- bootstrap step 4.
37
- Replaces the pre-#1251 ``step_ensure_gitignore_eval_dir`` which
38
- appended a blanket ``vbrief/.eval/`` line that violated the
39
- hybrid-policy decision recorded on #1144.
40
- - :func:`step_seed_candidates_log` -- bootstrap step 5 (#1240).
41
-
42
- Internal helpers (underscore-prefixed) MUST NOT be imported from
43
- outside :mod:`triage_bootstrap`. The companion ``StepOutcome`` dataclass
44
- is provided by the parent module to avoid a circular import.
45
- """
46
-
47
- from __future__ import annotations
48
-
49
- from pathlib import Path
50
- from typing import TYPE_CHECKING
51
-
52
- if TYPE_CHECKING:
53
- from triage_bootstrap import StepOutcome
54
-
55
-
56
- def _outcome_cls() -> type:
57
- """Return :class:`triage_bootstrap.StepOutcome` lazily.
58
-
59
- Lazy resolution sidesteps the import cycle between this submodule
60
- and :mod:`triage_bootstrap`: importing the parent at module load
61
- time would deadlock when a caller imports this submodule first
62
- (the parent's ``from _triage_bootstrap_gitignore import ...`` line
63
- runs before this module's name bindings are populated). Resolving
64
- on first call is cheap and Python caches the parent in
65
- ``sys.modules`` after the first hit.
66
- """
67
- from triage_bootstrap import StepOutcome as _StepOutcome
68
-
69
- return _StepOutcome
70
-
71
-
72
- #: Canonical gitignore line. Trailing slash matches the convention in
73
- #: the existing ``.gitignore`` (e.g. ``dist/``, ``node_modules/``).
74
- GITIGNORE_LINE: str = ".deft-cache/"
75
-
76
- #: Canonical selective gitignore lines for Deft-owned per-clone runtime
77
- #: sentinels under ``.deft/``. The framework payload at ``.deft/core/``
78
- #: remains intentionally trackable for reproducible consumer installs, so
79
- #: these entries MUST stay file-specific and MUST NOT become ``.deft/``.
80
- GITIGNORE_DEFT_RUNTIME_SENTINELS: tuple[str, ...] = (
81
- ".deft/ritual-state.json",
82
- ".deft/last-session.json",
83
- # Operator coding sub-agent model routing per #1739 -- per-machine,
84
- # per-project, never committed. The framework payload at .deft/core/
85
- # stays trackable, so this entry MUST stay file-specific.
86
- ".deft/routing.local.json",
87
- )
88
-
89
- #: Canonical selective gitignore lines for the #1144 hybrid policy.
90
- #: Replaces the pre-#1251 blanket ``vbrief/.eval/`` line. The entries
91
- #: below are operator-private / per-machine / local-scratch state;
92
- #: ``slices.jsonl`` is intentionally omitted because it is TRACKED
93
- #: team-shared cohort state per #1132 / D13. ``decompositions/`` holds
94
- #: local story-decomposition draft scratch; ``doctor-state.json`` is
95
- #: per-machine ``task doctor`` throttle state (added under #1464). This
96
- #: tuple is the single source of truth: the relocator imports it and the
97
- #: Go installer mirrors it (a parity test pins the two together), and it
98
- #: stays in lockstep with the ``vbrief/.eval/README.md`` policy table.
99
- GITIGNORE_EVAL_ENTRIES: tuple[str, ...] = (
100
- "vbrief/.eval/candidates.jsonl",
101
- "vbrief/.eval/summary-history.jsonl",
102
- "vbrief/.eval/scope-lifecycle.jsonl",
103
- "vbrief/.eval/decompositions/",
104
- "vbrief/.eval/doctor-state.json",
105
- )
106
-
107
- #: Canonical ``.gitattributes`` line for the #1144 merge=union rule on
108
- #: append-only JSONL files under ``vbrief/.eval/``. Two spaces between
109
- #: the glob and the attribute mirrors the existing repo convention.
110
- GITATTRIBUTES_EVAL_RULE: str = "vbrief/.eval/*.jsonl merge=union"
111
-
112
- #: Glob the merge=union rule must apply to. Used by the idempotency
113
- #: detector so we don't append a duplicate rule when an operator has
114
- #: hand-edited the attribute spacing or trailing comments.
115
- _GITATTRIBUTES_EVAL_GLOB: str = "vbrief/.eval/*.jsonl"
116
-
117
- #: Forbidden blanket gitignore lines. The pre-#1251 step appended
118
- #: ``vbrief/.eval/`` (or ``vbrief/.eval``) which silently hid the
119
- #: tracked ``slices.jsonl`` from git. Detected so we can warn loudly if
120
- #: a re-run encounters a stale entry left behind by a prior bootstrap.
121
- #: Public (#1464) so the installer (mirrored) and relocator (imported)
122
- #: deposit rails share one forbidden-blanket policy and HEAL a
123
- #: pre-existing blanket on upgrade instead of leaving it.
124
- FORBIDDEN_BLANKET_EVAL_LINES: tuple[str, ...] = (
125
- "vbrief/.eval/",
126
- "vbrief/.eval",
127
- )
128
- #: Backwards-compatible private alias for internal call sites that
129
- #: predate the public name promoted in #1464.
130
- _FORBIDDEN_BLANKET_EVAL_LINES: tuple[str, ...] = FORBIDDEN_BLANKET_EVAL_LINES
131
-
132
-
133
- _DEFT_CACHE_RATIONALE: str = (
134
- "\n# Triage v1 local content cache (#845, #883). Mirrors upstream\n"
135
- "# issues into .deft-cache/github-issue/<owner>/<repo>/<N>/. See\n"
136
- "# docs/privacy-nfr.md for the gitignore-default + opt-in-commit-cache\n"
137
- "# contract. Comment this line out to opt in to committing the cache.\n"
138
- )
139
- #: Comment block written above the selective eval entries on a fresh
140
- #: clone. Captures the #1144 hybrid policy in-line so an operator
141
- #: reading ``.gitignore`` sees why ``slices.jsonl`` is intentionally
142
- #: NOT listed (it is TRACKED team-shared cohort state).
143
- _EVAL_ENTRIES_RATIONALE: str = (
144
- "\n# vbrief/.eval/ tracking governance (#1144, N4 of #1119).\n"
145
- "# Hybrid policy from the Current Shape comment on #1144:\n"
146
- "# - candidates.jsonl -> gitignored (operator-private triage\n"
147
- "# decisions; re-derive via\n"
148
- "# `task triage:bootstrap` on a fresh\n"
149
- "# clone). #845 Story 2 + #915.\n"
150
- "# - summary-history.jsonl -> gitignored (operator-private\n"
151
- "# observability; not load-bearing for\n"
152
- "# any decision).\n"
153
- "# - scope-lifecycle.jsonl -> gitignored (operator-private\n"
154
- "# scope-lifecycle audit decisions;\n"
155
- "# D1 / #1121). Per-operator demote\n"
156
- "# stream; sharing would conflate\n"
157
- "# operators' demote timing across the\n"
158
- "# team.\n"
159
- "# - decompositions/ -> gitignored (local story-decomposition\n"
160
- "# draft scratch; generated child story\n"
161
- "# vBRIEFs live in lifecycle folders via\n"
162
- "# `task scope:decompose`).\n"
163
- "# - doctor-state.json -> gitignored (per-machine `task doctor`\n"
164
- "# throttle state gating the 24h/4h\n"
165
- "# re-probe window; #1308 / #1464). Local\n"
166
- "# to each clone; never committed.\n"
167
- "# - slices.jsonl -> TRACKED (team-shared cohort records\n"
168
- "# produced by slicing skills; see\n"
169
- "# #1132 / D13).\n"
170
- "# See vbrief/.eval/README.md for the full policy + merge=union\n"
171
- "# rebase note.\n"
172
- )
173
- _GITATTRIBUTES_EVAL_RATIONALE: str = (
174
- "\n# Append-only JSON-lines logs under vbrief/.eval/ use the union merge driver\n"
175
- "# (#1144, N4 of #1119). Both branches' appended lines are concatenated on\n"
176
- "# auto-merge so single-operator rebases of two append branches resolve\n"
177
- "# without manual conflict surgery. Note: merge=union does NOT dedupe; see\n"
178
- "# vbrief/.eval/README.md for the operator-facing semantics.\n"
179
- )
180
-
181
- #: First line of ``_EVAL_ENTRIES_RATIONALE`` used as the dedup sentinel
182
- #: when deciding whether to prepend the comment block on partial re-runs
183
- #: (Greptile P2 finding on PR #1256 -- a partial-state .gitignore that
184
- #: already carries the rationale block but is missing one or more
185
- #: selective entries should not get a duplicated comment block).
186
- _EVAL_ENTRIES_RATIONALE_SENTINEL: str = (
187
- "# vbrief/.eval/ tracking governance (#1144, N4 of #1119)."
188
- )
189
-
190
-
191
- def _strip_gitignore_inline_comment(line: str) -> str:
192
- """Strip an inline ``# ...`` comment from a gitignore line.
193
-
194
- Returns the line content with any trailing comment removed and
195
- surrounding whitespace stripped. A line whose entire content is a
196
- comment (after leading whitespace) returns an empty string. Used
197
- to detect forbidden blanket lines like ``vbrief/.eval/ # legacy``
198
- that would otherwise slip past the set-membership check (SLizard
199
- P1 finding on PR #1256).
200
- """
201
- stripped = line.strip()
202
- if not stripped:
203
- return ""
204
- if stripped.startswith("#"):
205
- return ""
206
- comment_idx = stripped.find("#")
207
- if comment_idx == -1:
208
- return stripped
209
- return stripped[:comment_idx].rstrip()
210
-
211
-
212
- #: Public alias for the inline-comment strip (#1464). The installer's
213
- #: Go heal mirrors this behaviour and the relocator's Python heal imports
214
- #: this exact helper so all three rails detect a forbidden blanket -- even
215
- #: one carrying a trailing ``# legacy`` comment -- identically.
216
- strip_gitignore_inline_comment = _strip_gitignore_inline_comment
217
-
218
-
219
- def _gitignore_already_covers(gitignore_text: str, line: str) -> bool:
220
- """Return True when ``gitignore_text`` already includes ``line``."""
221
-
222
- target = line.strip()
223
- return any(
224
- _strip_gitignore_inline_comment(raw) == target
225
- for raw in gitignore_text.splitlines()
226
- )
227
-
228
-
229
- def _is_commented_gitignore_line(raw: str, gitignore_line: str) -> bool:
230
- """Return True when ``raw`` is exactly the commented-out form of ``gitignore_line``."""
231
-
232
- stripped = raw.strip()
233
- if not stripped.startswith("#"):
234
- return False
235
- body = stripped.lstrip("#")
236
- if body.startswith(" "):
237
- body = body[1:]
238
- return body == gitignore_line
239
-
240
-
241
- def _ensure_gitignore_line(
242
- gitignore_path: Path,
243
- line: str,
244
- *,
245
- step_name: str,
246
- create_if_missing: bool,
247
- rationale_block: str,
248
- opt_in_message: str,
249
- ) -> StepOutcome:
250
- """Ensure ``line`` is present in ``.gitignore``; idempotent."""
251
-
252
- outcome_cls = _outcome_cls()
253
-
254
- if not gitignore_path.exists():
255
- if not create_if_missing:
256
- return outcome_cls(
257
- name=step_name,
258
- ok=False,
259
- message=(
260
- f".gitignore not present after the prior gitignore step; "
261
- f"{line} not written -- re-run bootstrap to retry"
262
- ),
263
- error="prior gitignore step did not create .gitignore",
264
- details={"created": False, "appended": False, "skipped": "no-gitignore"},
265
- )
266
- try:
267
- gitignore_path.write_text(line + "\n", encoding="utf-8")
268
- except OSError as exc:
269
- return outcome_cls(
270
- name=step_name,
271
- ok=False,
272
- message="could not create .gitignore",
273
- error=str(exc),
274
- )
275
- return outcome_cls(
276
- name=step_name,
277
- ok=True,
278
- message=f"created .gitignore with {line} line",
279
- details={"created": True, "appended": False},
280
- )
281
-
282
- try:
283
- existing = gitignore_path.read_text(encoding="utf-8")
284
- except (OSError, UnicodeDecodeError) as exc:
285
- return outcome_cls(
286
- name=step_name,
287
- ok=False,
288
- message="could not read .gitignore",
289
- error=str(exc),
290
- )
291
-
292
- has_commented_form = any(
293
- _is_commented_gitignore_line(raw, line) for raw in existing.splitlines()
294
- )
295
-
296
- if _gitignore_already_covers(existing, line):
297
- return outcome_cls(
298
- name=step_name,
299
- ok=True,
300
- message=f"{line} already in .gitignore (no-op)",
301
- details={"created": False, "appended": False, "already_present": True},
302
- )
303
-
304
- if has_commented_form:
305
- return outcome_cls(
306
- name=step_name,
307
- ok=True,
308
- message=opt_in_message,
309
- details={"created": False, "appended": False, "opt_in_commit": True},
310
- )
311
-
312
- suffix = "" if existing.endswith("\n") or existing == "" else "\n"
313
- new_content = existing + suffix + rationale_block + line + "\n"
314
- try:
315
- gitignore_path.write_text(new_content, encoding="utf-8")
316
- except OSError as exc:
317
- return outcome_cls(
318
- name=step_name,
319
- ok=False,
320
- message="could not write .gitignore",
321
- error=str(exc),
322
- )
323
- return outcome_cls(
324
- name=step_name,
325
- ok=True,
326
- message=f"appended {line} to .gitignore",
327
- details={"created": False, "appended": True},
328
- )
329
-
330
-
331
- def step_ensure_gitignore_entry(project_root: Path) -> StepOutcome:
332
- """Append ``.deft-cache/`` to ``.gitignore`` when absent."""
333
-
334
- return _ensure_gitignore_line(
335
- project_root / ".gitignore",
336
- GITIGNORE_LINE,
337
- step_name="ensure_gitignore_entry",
338
- create_if_missing=True,
339
- rationale_block=_DEFT_CACHE_RATIONALE,
340
- opt_in_message=(
341
- f"{GITIGNORE_LINE} is commented out (operator has opted in to "
342
- "commit the cache per docs/privacy-nfr.md NFR-2; not re-adding)"
343
- ),
344
- )
345
-
346
-
347
- def step_ensure_gitignore_eval_entries(project_root: Path) -> StepOutcome:
348
- """Ensure the #1144 hybrid policy is encoded in the repo (idempotent).
349
-
350
- Three sub-operations run unconditionally; each is independently
351
- idempotent and the aggregate StepOutcome reports the union of work
352
- done:
353
-
354
- 1. ``.gitignore`` -- append the three selective entries
355
- (``candidates.jsonl`` / ``summary-history.jsonl`` /
356
- ``scope-lifecycle.jsonl``) when any are missing. NEVER appends
357
- the blanket ``vbrief/.eval/`` line that violated #1144 -- the
358
- pre-#1251 behaviour. Refuses to create ``.gitignore`` from
359
- scratch; step 3 owns that responsibility.
360
- 2. ``.gitattributes`` -- append the ``vbrief/.eval/*.jsonl
361
- merge=union`` rule when absent. Creates the file on a fresh
362
- clone.
363
- 3. ``vbrief/.eval/README.md`` -- write the canonical hybrid-policy
364
- README when absent so operators reading the directory in
365
- isolation discover the tracking contract.
366
-
367
- All three operations are no-ops when the surface is already
368
- correctly configured (the framework's own repo case). The step is
369
- safe to re-run on every ``task triage:bootstrap`` invocation.
370
- """
371
- outcome_cls = _outcome_cls()
372
- gitignore_path = project_root / ".gitignore"
373
- gitattributes_path = project_root / ".gitattributes"
374
- readme_path = project_root / "vbrief" / ".eval" / "README.md"
375
- step_name = "ensure_gitignore_eval_entries"
376
-
377
- details: dict[str, object] = {}
378
-
379
- # Sub-op 1 -- .gitignore selective entries.
380
- gi_result = _ensure_gitignore_selective_entries(
381
- gitignore_path, step_name=step_name,
382
- )
383
- if not gi_result.ok:
384
- details.update(gi_result.details)
385
- return outcome_cls(
386
- name=step_name,
387
- ok=False,
388
- message=gi_result.message,
389
- error=gi_result.error,
390
- details=details,
391
- )
392
- details.update(gi_result.details)
393
-
394
- # Sub-op 2 -- .gitattributes merge=union rule.
395
- ga_result = _ensure_gitattributes_merge_union(
396
- gitattributes_path, step_name=step_name,
397
- )
398
- if not ga_result.ok:
399
- details.update(ga_result.details)
400
- return outcome_cls(
401
- name=step_name,
402
- ok=False,
403
- message=ga_result.message,
404
- error=ga_result.error,
405
- details=details,
406
- )
407
- details.update(ga_result.details)
408
-
409
- # Sub-op 3 -- README documents the policy.
410
- rd_result = _ensure_eval_readme(readme_path, step_name=step_name)
411
- if not rd_result.ok:
412
- details.update(rd_result.details)
413
- return outcome_cls(
414
- name=step_name,
415
- ok=False,
416
- message=rd_result.message,
417
- error=rd_result.error,
418
- details=details,
419
- )
420
- details.update(rd_result.details)
421
-
422
- appended_lines = int(details.get("gitignore_appended_lines", 0))
423
- appended_attr = bool(details.get("gitattributes_appended", False))
424
- created_readme = bool(details.get("readme_created", False))
425
- if not appended_lines and not appended_attr and not created_readme:
426
- message = (
427
- ".gitignore selective entries, .gitattributes merge=union, "
428
- "and vbrief/.eval/README.md already present (#1144 hybrid "
429
- "policy satisfied; no-op)"
430
- )
431
- else:
432
- parts: list[str] = []
433
- if appended_lines:
434
- parts.append(
435
- f"{appended_lines} selective .gitignore "
436
- f"entr{'y' if appended_lines == 1 else 'ies'}"
437
- )
438
- if appended_attr:
439
- parts.append(".gitattributes merge=union rule")
440
- if created_readme:
441
- parts.append("vbrief/.eval/README.md")
442
- message = "wrote " + " + ".join(parts) + " per #1144 hybrid policy"
443
- # Greptile P1 on PR #1256: propagate the stale-blanket warning
444
- # through to the outer step's message so it reaches
445
- # ``run_bootstrap``'s progress emit + the recap (the sub-step's
446
- # message was discarded by the aggregator before this fix).
447
- message = message + _format_blanket_warning(
448
- bool(details.get("blanket_present", False))
449
- )
450
- return outcome_cls(
451
- name=step_name,
452
- ok=True,
453
- message=message,
454
- details=details,
455
- )
456
-
457
-
458
- def _ensure_gitignore_selective_entries(
459
- gitignore_path: Path,
460
- *,
461
- step_name: str,
462
- ) -> StepOutcome:
463
- """Append any missing #1144 selective entries to ``.gitignore``.
464
-
465
- Idempotent: when every selective entry is already present, the
466
- file is left untouched. When the ``.gitignore`` itself is absent
467
- we refuse (step 3 owns creation) so an out-of-order call surfaces
468
- loudly. The forbidden blanket line ``vbrief/.eval/`` is never
469
- appended and a warning is logged in ``details`` when an operator
470
- has left one behind manually (the bootstrap does NOT rewrite it --
471
- the workaround documented on #1251 is for the operator to remove
472
- it; auto-rewriting risks racing with concurrent edits).
473
- """
474
- outcome_cls = _outcome_cls()
475
-
476
- if not gitignore_path.exists():
477
- return outcome_cls(
478
- name=step_name,
479
- ok=False,
480
- message=(
481
- ".gitignore not present after the prior gitignore step; "
482
- "selective eval entries not written -- re-run bootstrap"
483
- ),
484
- error="prior gitignore step did not create .gitignore",
485
- details={
486
- "gitignore_appended_lines": 0,
487
- "skipped": "no-gitignore",
488
- },
489
- )
490
-
491
- try:
492
- existing = gitignore_path.read_text(encoding="utf-8")
493
- except (OSError, UnicodeDecodeError) as exc:
494
- return outcome_cls(
495
- name=step_name,
496
- ok=False,
497
- message="could not read .gitignore",
498
- error=str(exc),
499
- details={"gitignore_appended_lines": 0},
500
- )
501
-
502
- # SLizard P1 finding on PR #1256: the previous detector used the
503
- # whole stripped line (including inline comments) for set
504
- # membership, so a blanket entry like ``vbrief/.eval/ # legacy``
505
- # slipped past the forbidden check. Now strip the inline comment
506
- # before building the membership set + scanning for forbidden
507
- # blanket lines.
508
- existing_lines = {
509
- stripped
510
- for raw in existing.splitlines()
511
- if (stripped := _strip_gitignore_inline_comment(raw))
512
- }
513
- blanket_present = any(
514
- forbidden in existing_lines
515
- for forbidden in _FORBIDDEN_BLANKET_EVAL_LINES
516
- )
517
- # Greptile P2 finding on PR #1256: dedup the rationale comment
518
- # block across partial re-runs (operator deleted one of the three
519
- # entries manually; re-run should append the missing entry without
520
- # re-prepending the rationale).
521
- rationale_already_present = _EVAL_ENTRIES_RATIONALE_SENTINEL in existing
522
-
523
- missing = [
524
- entry for entry in GITIGNORE_EVAL_ENTRIES
525
- if entry not in existing_lines
526
- ]
527
- blanket_warning = _format_blanket_warning(blanket_present)
528
- if not missing:
529
- return outcome_cls(
530
- name=step_name,
531
- ok=True,
532
- message=(
533
- "all #1144 selective entries already in .gitignore (no-op)"
534
- + blanket_warning
535
- ),
536
- details={
537
- "gitignore_appended_lines": 0,
538
- "gitignore_already_selective": True,
539
- "blanket_present": blanket_present,
540
- },
541
- )
542
-
543
- suffix = "" if existing.endswith("\n") or existing == "" else "\n"
544
- if rationale_already_present:
545
- appended_block = "\n".join(missing) + "\n"
546
- else:
547
- appended_block = _EVAL_ENTRIES_RATIONALE + "\n".join(missing) + "\n"
548
- new_content = existing + suffix + appended_block
549
- try:
550
- gitignore_path.write_text(new_content, encoding="utf-8")
551
- except OSError as exc:
552
- return outcome_cls(
553
- name=step_name,
554
- ok=False,
555
- message="could not write .gitignore",
556
- error=str(exc),
557
- details={"gitignore_appended_lines": 0},
558
- )
559
- return outcome_cls(
560
- name=step_name,
561
- ok=True,
562
- message=(
563
- f"appended {len(missing)} selective .gitignore "
564
- f"entr{'y' if len(missing) == 1 else 'ies'}"
565
- + blanket_warning
566
- ),
567
- details={
568
- "gitignore_appended_lines": len(missing),
569
- "gitignore_appended_entries": list(missing),
570
- "blanket_present": blanket_present,
571
- "rationale_already_present": rationale_already_present,
572
- },
573
- )
574
-
575
-
576
- def _format_blanket_warning(blanket_present: bool) -> str:
577
- """Return the operator-visible warning suffix when a blanket line is detected.
578
-
579
- Greptile P1 finding on PR #1256: when an operator who ran the
580
- pre-#1251 bootstrap upgrades, their ``.gitignore`` still carries
581
- the stale ``vbrief/.eval/`` blanket line that hides ``slices.jsonl``
582
- from git. Detecting it but reporting only ``hybrid policy
583
- satisfied; no-op`` left the operator unaware their repo was still
584
- broken. The warning surfaces in ``StepOutcome.message`` so it
585
- flows through ``run_bootstrap`` 's progress emit AND the recap.
586
- The forbidden line is NEVER auto-rewritten (concurrency safety);
587
- the operator removes it manually per the #1251 workaround.
588
- """
589
- if not blanket_present:
590
- return ""
591
- return (
592
- " WARNING: stale blanket vbrief/.eval/ line detected in .gitignore -- "
593
- "remove it manually (it hides tracked slices.jsonl from git per #1251)"
594
- )
595
-
596
-
597
- def _ensure_gitattributes_merge_union(
598
- gitattributes_path: Path,
599
- *,
600
- step_name: str,
601
- ) -> StepOutcome:
602
- """Ensure the ``vbrief/.eval/*.jsonl merge=union`` rule is present.
603
-
604
- Idempotent. Detects an existing rule that targets the canonical
605
- glob ``vbrief/.eval/*.jsonl`` with ``merge=union`` (regardless of
606
- whitespace between the glob and the attribute) so a hand-edited
607
- file with single-space spacing or trailing comments is recognised
608
- as already-satisfied. Creates the file on a fresh clone.
609
- """
610
- outcome_cls = _outcome_cls()
611
-
612
- if gitattributes_path.exists():
613
- try:
614
- existing = gitattributes_path.read_text(encoding="utf-8")
615
- except (OSError, UnicodeDecodeError) as exc:
616
- return outcome_cls(
617
- name=step_name,
618
- ok=False,
619
- message="could not read .gitattributes",
620
- error=str(exc),
621
- details={"gitattributes_appended": False},
622
- )
623
- if _gitattributes_has_eval_merge_union(existing):
624
- return outcome_cls(
625
- name=step_name,
626
- ok=True,
627
- message=(
628
- "vbrief/.eval/*.jsonl merge=union already in "
629
- ".gitattributes (no-op)"
630
- ),
631
- details={
632
- "gitattributes_appended": False,
633
- "gitattributes_already_present": True,
634
- },
635
- )
636
- suffix = "" if existing.endswith("\n") or existing == "" else "\n"
637
- new_content = (
638
- existing
639
- + suffix
640
- + _GITATTRIBUTES_EVAL_RATIONALE
641
- + GITATTRIBUTES_EVAL_RULE
642
- + "\n"
643
- )
644
- try:
645
- gitattributes_path.write_text(new_content, encoding="utf-8")
646
- except OSError as exc:
647
- return outcome_cls(
648
- name=step_name,
649
- ok=False,
650
- message="could not write .gitattributes",
651
- error=str(exc),
652
- details={"gitattributes_appended": False},
653
- )
654
- return outcome_cls(
655
- name=step_name,
656
- ok=True,
657
- message=(
658
- "appended vbrief/.eval/*.jsonl merge=union to .gitattributes"
659
- ),
660
- details={
661
- "gitattributes_appended": True,
662
- "gitattributes_created": False,
663
- },
664
- )
665
-
666
- new_content = (
667
- _GITATTRIBUTES_EVAL_RATIONALE + GITATTRIBUTES_EVAL_RULE + "\n"
668
- )
669
- try:
670
- gitattributes_path.write_text(new_content, encoding="utf-8")
671
- except OSError as exc:
672
- return outcome_cls(
673
- name=step_name,
674
- ok=False,
675
- message="could not create .gitattributes",
676
- error=str(exc),
677
- details={"gitattributes_appended": False},
678
- )
679
- return outcome_cls(
680
- name=step_name,
681
- ok=True,
682
- message=(
683
- "created .gitattributes with vbrief/.eval/*.jsonl merge=union"
684
- ),
685
- details={
686
- "gitattributes_appended": True,
687
- "gitattributes_created": True,
688
- },
689
- )
690
-
691
-
692
- def _gitattributes_has_eval_merge_union(body: str) -> bool:
693
- """Return True when ``body`` already carries the merge=union rule.
694
-
695
- Tolerant of arbitrary whitespace between the glob and the attribute
696
- plus trailing comments / extra attributes on the same line. A
697
- line beginning with ``#`` does not satisfy the rule.
698
- """
699
- for raw in body.splitlines():
700
- stripped = raw.strip()
701
- if not stripped or stripped.startswith("#"):
702
- continue
703
- # Tokenise on whitespace; first token is the pattern.
704
- parts = stripped.split()
705
- if not parts:
706
- continue
707
- if parts[0] != _GITATTRIBUTES_EVAL_GLOB:
708
- continue
709
- if "merge=union" in parts[1:]:
710
- return True
711
- return False
712
-
713
-
714
- def _ensure_eval_readme(
715
- readme_path: Path,
716
- *,
717
- step_name: str,
718
- ) -> StepOutcome:
719
- """Write ``vbrief/.eval/README.md`` when absent.
720
-
721
- Idempotent: a pre-existing README (operator-edited or framework-
722
- shipped) is left untouched. The bootstrap is intentionally
723
- non-destructive here -- if the framework's canonical README drifts
724
- relative to a consumer's edited copy, that's an upgrade-time
725
- concern, not a bootstrap-time concern.
726
- """
727
- outcome_cls = _outcome_cls()
728
- if readme_path.exists():
729
- return outcome_cls(
730
- name=step_name,
731
- ok=True,
732
- message="vbrief/.eval/README.md already present (no-op)",
733
- details={
734
- "readme_created": False,
735
- "readme_already_present": True,
736
- },
737
- )
738
- try:
739
- readme_path.parent.mkdir(parents=True, exist_ok=True)
740
- readme_path.write_text(_EVAL_README_BODY, encoding="utf-8")
741
- except OSError as exc:
742
- return outcome_cls(
743
- name=step_name,
744
- ok=False,
745
- message=f"could not create {readme_path}",
746
- error=str(exc),
747
- details={"readme_created": False},
748
- )
749
- return outcome_cls(
750
- name=step_name,
751
- ok=True,
752
- message="created vbrief/.eval/README.md (#1144 hybrid policy)",
753
- details={"readme_created": True},
754
- )
755
-
756
-
757
- #: Canonical README body written on a fresh clone. Mirrors the on-disk
758
- #: copy at ``vbrief/.eval/README.md`` so the framework's own repo and
759
- #: a consumer's fresh clone produce byte-identical files. The content
760
- #: satisfies the deterministic gates in
761
- #: ``tests/test_eval_governance.py::test_eval_readme_documents_policy``
762
- #: (the tracked/gitignored filenames including ``doctor-state.json``,
763
- #: the ``task triage:bootstrap`` regen command, the ``merge=union``
764
- #: policy, and the no-dedupe qualifier). The markdown table rows below
765
- #: intentionally run past the 100-char ceiling so the rendered README
766
- #: mirrors the canonical on-disk file; see the module-level lint
767
- #: exemption at the top of this file for the rationale.
768
- _EVAL_README_BODY: str = """# `vbrief/.eval/` -- triage + slicing evaluation artefacts
769
-
770
- This directory holds the append-only JSON-lines logs that the triage and
771
- slicing skills emit. The framework governs which files in here are tracked
772
- by git versus gitignored using a **hybrid policy** (#1144, child of #1119).
773
-
774
- ## Tracking policy
775
-
776
- | File | Tracked? | Why |
777
- | --- | --- | --- |
778
- | `slices.jsonl` | Yes -- **committed** | Team-shared cohort records produced by slicing skills (D13 / #1132). New operators joining the team need to see prior cohort outputs to detect orphans and avoid re-slicing the same scope. |
779
- | `candidates.jsonl` | No -- **gitignored** | Operator-private triage decisions (#845 Story 2). Each operator's local accept / defer / reject stream is per-machine state; sharing it would conflate operators' timing + identity across the team. Re-derive on a fresh clone via `task triage:bootstrap`. |
780
- | `summary-history.jsonl` | No -- **gitignored** | Operator-private observability for `task triage:summary` output time-series. Not load-bearing for any decision. |
781
- | `scope-lifecycle.jsonl` | No -- **gitignored** | Operator-private scope-lifecycle audit decisions (D1 / #1121). Each demote (`task scope:demote`) appends one entry including a `demote_meta` block (`was_promoted`, `original_promotion_decision_id`, `days_in_pending`, `demote_reason`, `demoted_from`). Per-operator stream; sharing would conflate operators' demote timing across the team. Lightweight metrics over this log are tracked separately at #1180. |
782
- | `decompositions/` | No -- **gitignored** | Temporary story-decomposition proposal drafts. These JSON drafts are local scratch artifacts, not vBRIEFs; generated child story vBRIEFs are created by `task scope:decompose` in lifecycle folders, defaulting to `vbrief/pending/`. |
783
- | `doctor-state.json` | No -- **gitignored** | Per-machine `task doctor` throttle state (last exit code + timestamps) persisted to gate the 24h/4h re-probe window (#1308 / #1464). Local to each clone; never committed. |
784
-
785
- The gitignore lines live in the repo-root `.gitignore` (`vbrief/.eval/candidates.jsonl`,
786
- `vbrief/.eval/summary-history.jsonl`, `vbrief/.eval/scope-lifecycle.jsonl`,
787
- `vbrief/.eval/decompositions/`, and `vbrief/.eval/doctor-state.json`). All paths
788
- not listed above remain committed by default.
789
-
790
- ## Fresh-clone regeneration
791
-
792
- On a fresh clone (or any machine that has never run triage), `candidates.jsonl`
793
- is absent. Regenerate it with:
794
-
795
- ```
796
- task triage:bootstrap
797
- ```
798
-
799
- The bootstrap path detects the missing file, runs the auto-classifier, and
800
- writes a fresh `vbrief/.eval/candidates.jsonl`. It does NOT touch the tracked
801
- `slices.jsonl`; cohort records remain a team-shared resource.
802
-
803
- ## `merge=union` policy for `*.jsonl`
804
-
805
- The repo-root `.gitattributes` declares:
806
-
807
- ```
808
- vbrief/.eval/*.jsonl merge=union
809
- ```
810
-
811
- The `union` merge driver concatenates both sides' appended lines on
812
- auto-merge, so two branches that each appended a different record to the
813
- same JSON-lines file rebase cleanly without operator surgery. Two things
814
- operators should know:
815
-
816
- - **Concatenation, not set-union.** When two branches append DIFFERENT
817
- records to the file, the merge driver concatenates both sides' lines
818
- -- there is no smart deduplication of "semantically similar" records.
819
- (Identical line-for-line appends collapse because git's three-way
820
- merge sees them as the same change, but distinct records always
821
- survive verbatim, even if a downstream reader would consider them
822
- redundant.) The append-only writers in `scripts/candidates_log.py`
823
- mint a fresh `decision_id` per call, so genuinely duplicate records
824
- are not the expected case, but downstream readers MUST tolerate
825
- multiple records describing the same logical decision.
826
- - **Single-operator scope only.** This is the foundational rebase
827
- ergonomic for the single-operator case (operator A rebases their
828
- feature branch onto a master that grew while they were AFK).
829
- Multi-operator merge-conflict resolution is explicitly out of scope per
830
- #1119 R4 (tracked separately as M1-M4 in #1183).
831
-
832
- ## See also
833
-
834
- - Current Shape comment on #1144 for the canonical decisions (the source
835
- of truth this README documents).
836
- - `.gitignore` -- selective gitignore entries for the operator-private
837
- files.
838
- - `.gitattributes` -- the `merge=union` rule.
839
- - `scripts/candidates_log.py` -- the writer for `candidates.jsonl`.
840
- """
841
-
842
-
843
- #: Canonical relative location of the audit log; mirrors
844
- #: :data:`triage_bootstrap.AUDIT_LOG_RELPATH` (re-stated here to avoid an
845
- #: import cycle with the parent module).
846
- _CANDIDATES_RELPATH: Path = Path("vbrief") / ".eval" / "candidates.jsonl"
847
-
848
-
849
- def step_seed_candidates_log(project_root: Path) -> StepOutcome:
850
- """Ensure ``vbrief/.eval/candidates.jsonl`` exists (#1240 option A).
851
-
852
- Bootstrap previously left the audit log absent on the happy path
853
- (no items to backfill). ``task verify:cache-fresh`` then exited
854
- with the ``treating as bootstrap state`` message because it could
855
- not distinguish a never-bootstrapped consumer from a freshly-
856
- bootstrapped one. Per issue #1240 option A we seed an empty
857
- zero-length ``candidates.jsonl`` so the two surfaces agree on a
858
- single state machine: post-bootstrap the gate sees both the cache
859
- AND the audit log, and reports ``fresh bootstrap, no triage
860
- actions yet`` (or the canonical fresh / actively-triaging message
861
- once decisions are recorded).
862
-
863
- Idempotent: a pre-existing audit log (zero-length or filled) is
864
- left untouched. The step succeeds with a no-op message in that
865
- case so a re-run of ``task triage:bootstrap`` does not perturb
866
- existing audit state.
867
- """
868
- outcome_cls = _outcome_cls()
869
- audit_path = project_root / _CANDIDATES_RELPATH
870
- audit_dir = audit_path.parent
871
- try:
872
- audit_dir.mkdir(parents=True, exist_ok=True)
873
- except OSError as exc:
874
- return outcome_cls(
875
- name="seed_candidates_log",
876
- ok=False,
877
- message=f"could not create {audit_dir}",
878
- error=str(exc),
879
- )
880
- if audit_path.exists():
881
- return outcome_cls(
882
- name="seed_candidates_log",
883
- ok=True,
884
- message=f"{audit_path.relative_to(project_root)} already present (no-op)",
885
- details={"created": False, "already_present": True},
886
- )
887
- try:
888
- # Zero-byte touch: open in append mode + close. open("a") is
889
- # the canonical "create if missing, otherwise noop" primitive
890
- # and avoids race conditions on concurrent bootstrap runs.
891
- audit_path.touch()
892
- except OSError as exc:
893
- return outcome_cls(
894
- name="seed_candidates_log",
895
- ok=False,
896
- message=f"could not seed {audit_path}",
897
- error=str(exc),
898
- )
899
- return outcome_cls(
900
- name="seed_candidates_log",
901
- ok=True,
902
- message=f"created empty {audit_path.relative_to(project_root)}",
903
- details={"created": True, "already_present": False},
904
- )