@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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,904 @@
|
|
|
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
|
+
)
|