its-magic 0.1.3-3 → 0.1.3-4
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/package.json +1 -1
- package/scripts/check_intake_template_parity.py +71 -0
- package/template/.cursor/commands/auto.md +54 -4
- package/template/.cursor/commands/closure.md +184 -0
- package/template/.cursor/commands/intake.md +26 -0
- package/template/.cursor/commands/release.md +17 -26
- package/template/.cursor/scratchpad.local.example.md +168 -70
- package/template/docs/engineering/autonomy-stop-matrix.md +101 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +12 -0
- package/template/docs/engineering/runbook.md +3869 -3576
- package/template/its_magic/README.md +3981 -6
- package/template/scripts/autonomy_preset_lib.py +224 -0
- package/template/scripts/check_intake_template_parity.py +71 -0
- package/template/scripts/validate_autonomy_stop_matrix.py +486 -0
- package/template/scripts/validate_closure_verification.py +304 -0
- package/template/scripts/work_kind_classify_lib.py +543 -0
- package/template/scripts/work_kind_routing_lib.py +318 -0
- package/template/tests/__pycache__/scratchpad_example_parity_test.cpython-312-pytest-9.0.2.pyc +0 -0
- package/template/tests/us0118_contract_test.py +330 -0
- package/template/tests/us0119_autonomy_preset_test.py +168 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Work-kind classification + tiered delivery routing per story (US-0118 / DEC-0118).
|
|
3
|
+
|
|
4
|
+
Deterministic pure-stdlib per-story work-kind classifier that returns
|
|
5
|
+
``work_kind ∈ {doc, mini, code}`` plus a recommended delivery mode and phase
|
|
6
|
+
plan. Reuses ``dev_environment_lib.classify_touched_files`` (tier A/B/C +
|
|
7
|
+
``TIER_C_SKIP_PREFIXES``) via import — never duplicates the tier rules.
|
|
8
|
+
|
|
9
|
+
The classifier is gated by the ``WORK_KIND_ROUTING`` scratchpad flag
|
|
10
|
+
(default ``0`` — zero overhead when off). When ``1`` the classifier is
|
|
11
|
+
consulted by ``/intake`` step 5 and ``/auto`` ``resolve_delivery_mode``
|
|
12
|
+
step 0 per the L8 precedence chain (explicit ``DELIVERY_MODE`` >
|
|
13
|
+
``AUTO_PHASE_*`` > ``WORK_KIND_ROUTING``-derived > current default).
|
|
14
|
+
|
|
15
|
+
Reason codes (``WORK_KIND_*`` family, R-0106 Q2 LOCKED):
|
|
16
|
+
- ``WORK_KIND_ROUTING_OFF`` — info-only; ``WORK_KIND_ROUTING != "1"``.
|
|
17
|
+
- ``WORK_KIND_DELIVERY_MODE_CONFLICT`` — explicit ``DELIVERY_MODE`` set
|
|
18
|
+
and conflicts with the classifier recommendation.
|
|
19
|
+
- ``WORK_KIND_CLASSIFY_FAILED`` — classifier raised / returned malformed.
|
|
20
|
+
- ``WORK_KIND_UNKNOWN_ROUTE`` — work_kind value not in {doc, mini, code}.
|
|
21
|
+
- ``WORK_KIND_PLAN_COVERAGE_MISSING`` — empty/invalid phase plan.
|
|
22
|
+
- ``WORK_KIND_TIE_BREAK_APPLIED`` — info-only; mixed tiers resolved by
|
|
23
|
+
the highest-tier-wins tie-break (Q1 LOCKED).
|
|
24
|
+
|
|
25
|
+
Intake evidence schema extension (AC-9 / R-0106 Q9): the intake evidence
|
|
26
|
+
JSON gains three optional fields when the classifier runs at ``/intake``
|
|
27
|
+
step 5 — ``work_kind``, ``recommended_delivery_mode``,
|
|
28
|
+
``work_kind_operator_decision ∈ {accept, override}``. Existing intake
|
|
29
|
+
evidence files are NOT modified; only the schema contract is documented
|
|
30
|
+
here.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from dataclasses import asdict, dataclass, field
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
# Path bootstrap so the bare ``import dev_environment_lib`` resolves
|
|
43
|
+
# whether the script is run from the repo root or as a file path.
|
|
44
|
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
45
|
+
if _SCRIPT_DIR not in sys.path:
|
|
46
|
+
sys.path.insert(0, _SCRIPT_DIR)
|
|
47
|
+
|
|
48
|
+
# Q9 LOCKED import contract — import, do NOT duplicate. The tier A/B/C
|
|
49
|
+
# classification + skip-prefix single source of truth lives in
|
|
50
|
+
# ``dev_environment_lib`` so that drift between the two rule engines is
|
|
51
|
+
# impossible by construction.
|
|
52
|
+
import dev_environment_lib # noqa: E402
|
|
53
|
+
from dev_environment_lib import ( # noqa: E402
|
|
54
|
+
TIER_C_SKIP_PREFIXES,
|
|
55
|
+
classify_touched_files,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Enumeration + dataclass (R-0106 Q10 LOCKED signature)
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WorkKind(str, Enum):
|
|
65
|
+
"""Three-tier work-kind enumeration (DEC-0118 §1)."""
|
|
66
|
+
|
|
67
|
+
DOC = "doc"
|
|
68
|
+
MINI = "mini"
|
|
69
|
+
CODE = "code"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class WorkKindClassification:
|
|
74
|
+
"""
|
|
75
|
+
Result of :func:`classify_work_kind`.
|
|
76
|
+
|
|
77
|
+
Fields mirror R-0106 Q10 LOCKED signature. ``rule_trace`` is populated
|
|
78
|
+
only when ``explain=True`` (Q3 LOCKED — ``--explain`` flag).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
work_kind: WorkKind
|
|
82
|
+
recommended_delivery_mode: str
|
|
83
|
+
recommended_phase_plan: List[str]
|
|
84
|
+
rationale: str
|
|
85
|
+
evidence_refs: List[str] = field(default_factory=list)
|
|
86
|
+
rule_trace: List[Tuple[str, str, str]] = field(default_factory=list)
|
|
87
|
+
# Intake-evidence schema extension fields (AC-9). Populated so that
|
|
88
|
+
# consumers (e.g. ``/intake`` step 5) can persist them directly into
|
|
89
|
+
# the intake evidence bundle without re-deriving.
|
|
90
|
+
work_kind_operator_decision: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
93
|
+
"""Serialize to a JSON-friendly dict (enum → string)."""
|
|
94
|
+
d = asdict(self)
|
|
95
|
+
d["work_kind"] = self.work_kind.value
|
|
96
|
+
d["rule_trace"] = [list(t) for t in self.rule_trace]
|
|
97
|
+
return d
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Constants — phase plans + reason codes (R-0106 Q2 LOCKED)
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
DOC_PHASE_PLAN: Tuple[str, ...] = ("intake", "execute", "release")
|
|
106
|
+
MINI_ULTRA_LEAN_PHASE_PLAN: Tuple[str, ...] = (
|
|
107
|
+
"spec",
|
|
108
|
+
"plan",
|
|
109
|
+
"build+verify",
|
|
110
|
+
"ship",
|
|
111
|
+
)
|
|
112
|
+
MINI_MEGA_QUICK_PHASE_PLAN: Tuple[str, ...] = ("quick",)
|
|
113
|
+
CODE_STANDARD_PHASE_PLAN: Tuple[str, ...] = (
|
|
114
|
+
"intake",
|
|
115
|
+
"discovery",
|
|
116
|
+
"research",
|
|
117
|
+
"architecture",
|
|
118
|
+
"sprint-plan",
|
|
119
|
+
"plan-verify",
|
|
120
|
+
"execute",
|
|
121
|
+
"qa",
|
|
122
|
+
"verify-work",
|
|
123
|
+
"release",
|
|
124
|
+
"refresh-context",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Reason-code family (R-0106 Q2 LOCKED + AC-7). ``WORK_KIND_ROUTING_OFF``
|
|
128
|
+
# and ``WORK_KIND_TIE_BREAK_APPLIED`` are info-only (not fail-closed); the
|
|
129
|
+
# other four are fail-closed and emit remediation guidance in
|
|
130
|
+
# ``sprints/Sxxxx/qa-findings.md`` / ``release-findings.md``.
|
|
131
|
+
WORK_KIND_ROUTING_OFF = "WORK_KIND_ROUTING_OFF"
|
|
132
|
+
WORK_KIND_DELIVERY_MODE_CONFLICT = "WORK_KIND_DELIVERY_MODE_CONFLICT"
|
|
133
|
+
WORK_KIND_CLASSIFY_FAILED = "WORK_KIND_CLASSIFY_FAILED"
|
|
134
|
+
WORK_KIND_UNKNOWN_ROUTE = "WORK_KIND_UNKNOWN_ROUTE"
|
|
135
|
+
WORK_KIND_PLAN_COVERAGE_MISSING = "WORK_KIND_PLAN_COVERAGE_MISSING"
|
|
136
|
+
WORK_KIND_TIE_BREAK_APPLIED = "WORK_KIND_TIE_BREAK_APPLIED"
|
|
137
|
+
|
|
138
|
+
WORK_KIND_REASON_CODES: Tuple[str, ...] = (
|
|
139
|
+
WORK_KIND_ROUTING_OFF,
|
|
140
|
+
WORK_KIND_DELIVERY_MODE_CONFLICT,
|
|
141
|
+
WORK_KIND_CLASSIFY_FAILED,
|
|
142
|
+
WORK_KIND_UNKNOWN_ROUTE,
|
|
143
|
+
WORK_KIND_PLAN_COVERAGE_MISSING,
|
|
144
|
+
WORK_KIND_TIE_BREAK_APPLIED,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
REASON_CODE_REMEDIATION: Dict[str, str] = {
|
|
148
|
+
WORK_KIND_ROUTING_OFF: (
|
|
149
|
+
"Set WORK_KIND_ROUTING=1 to enable per-story routing; current "
|
|
150
|
+
"behavior unchanged."
|
|
151
|
+
),
|
|
152
|
+
WORK_KIND_DELIVERY_MODE_CONFLICT: (
|
|
153
|
+
"Explicit DELIVERY_MODE wins per L8 precedence; either unset "
|
|
154
|
+
"DELIVERY_MODE to allow work-kind routing or update the backlog "
|
|
155
|
+
"row; mid-story switch forbidden (DELIVERY_MODE_SWITCH_MID_STORY)."
|
|
156
|
+
),
|
|
157
|
+
WORK_KIND_CLASSIFY_FAILED: (
|
|
158
|
+
"Re-run /intake with explicit work_kind override; inspect "
|
|
159
|
+
"--explain trace; file bug if rule engine is at fault."
|
|
160
|
+
),
|
|
161
|
+
WORK_KIND_UNKNOWN_ROUTE: (
|
|
162
|
+
"Re-run classifier; if persistent, set DELIVERY_MODE explicitly "
|
|
163
|
+
"or add AUTO_PHASE_* override; default to standard lifecycle."
|
|
164
|
+
),
|
|
165
|
+
WORK_KIND_PLAN_COVERAGE_MISSING: (
|
|
166
|
+
"Re-run classifier; if persistent, set DELIVERY_MODE explicitly "
|
|
167
|
+
"or add AUTO_PHASE_* override; default to standard lifecycle."
|
|
168
|
+
),
|
|
169
|
+
WORK_KIND_TIE_BREAK_APPLIED: (
|
|
170
|
+
"Mixed-tier story resolved by highest-tier-wins tie-break "
|
|
171
|
+
"(code > mini > doc). Inspect --explain trace to override."
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Tier → work_kind mapping (Q1 LOCKED). Mirrors
|
|
176
|
+
# ``classify_touched_files`` tier_rank A>B>C where A=code, B=mini, C=doc.
|
|
177
|
+
TIER_TO_WORK_KIND: Dict[str, WorkKind] = {
|
|
178
|
+
"A": WorkKind.CODE,
|
|
179
|
+
"B": WorkKind.MINI,
|
|
180
|
+
"C": WorkKind.DOC,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# US-0096 mega_quick eligibility signals (L6 LOCKED). The classifier
|
|
184
|
+
# recommends ``mega_quick`` only when all of these hold; otherwise it
|
|
185
|
+
# falls back to ``ultra_lean`` for the ``mini`` route.
|
|
186
|
+
MEGA_QUICK_MAX_AC = 3
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Helpers
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _looks_like_markdown_under_skip_prefix(rel: str) -> bool:
|
|
195
|
+
"""Return True for ``*.md`` / ``README*`` under a skip prefix (L5)."""
|
|
196
|
+
norm = rel.replace("\\", "/")
|
|
197
|
+
if not any(norm.startswith(p) for p in TIER_C_SKIP_PREFIXES):
|
|
198
|
+
return False
|
|
199
|
+
base = norm.rsplit("/", 1)[-1]
|
|
200
|
+
return base.lower().endswith(".md") or base.lower().startswith("readme")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_work_kind_from_tier(
|
|
204
|
+
tier: Optional[str],
|
|
205
|
+
touched_file_hints: List[str],
|
|
206
|
+
explain: bool,
|
|
207
|
+
) -> Tuple[WorkKind, List[Tuple[str, str, str]]]:
|
|
208
|
+
"""
|
|
209
|
+
Map the highest matched tier to a work_kind (Q1 LOCKED tie-break).
|
|
210
|
+
|
|
211
|
+
``classify_touched_files`` already returns the *highest* matching tier
|
|
212
|
+
(A>B>C). When the story touches both ``docs/`` and ``src/`` paths the
|
|
213
|
+
highest tier wins — ``code`` > ``mini`` > ``doc``.
|
|
214
|
+
"""
|
|
215
|
+
trace: List[Tuple[str, str, str]] = []
|
|
216
|
+
if tier is None:
|
|
217
|
+
# No matched source path. If every touched file is a doc/markdown
|
|
218
|
+
# under a skip prefix → DOC; otherwise fall back to DOC as the
|
|
219
|
+
# conservative default (no runtime surface detected).
|
|
220
|
+
all_doc = bool(touched_file_hints) and all(
|
|
221
|
+
_looks_like_markdown_under_skip_prefix(h) or any(
|
|
222
|
+
h.replace("\\", "/").startswith(p) for p in TIER_C_SKIP_PREFIXES
|
|
223
|
+
)
|
|
224
|
+
for h in touched_file_hints
|
|
225
|
+
)
|
|
226
|
+
if explain:
|
|
227
|
+
trace.append((
|
|
228
|
+
"rule.doc.no_runtime_surface",
|
|
229
|
+
f"tier={tier}, hints={len(touched_file_hints)}",
|
|
230
|
+
"doc" if all_doc else "doc (conservative default)",
|
|
231
|
+
))
|
|
232
|
+
return WorkKind.DOC, trace
|
|
233
|
+
|
|
234
|
+
work_kind = TIER_TO_WORK_KIND.get(tier, WorkKind.DOC)
|
|
235
|
+
if explain:
|
|
236
|
+
trace.append((
|
|
237
|
+
"rule.tier.highest_wins",
|
|
238
|
+
f"tier={tier}",
|
|
239
|
+
work_kind.value,
|
|
240
|
+
))
|
|
241
|
+
# Tie-break signal: when any touched file is under a skip prefix
|
|
242
|
+
# AND the highest tier is A or B, the highest-tier-wins rule
|
|
243
|
+
# resolved a mixed-tier story (Q1 LOCKED).
|
|
244
|
+
has_skip_prefix = any(
|
|
245
|
+
any(h.replace("\\", "/").startswith(p) for p in TIER_C_SKIP_PREFIXES)
|
|
246
|
+
for h in touched_file_hints
|
|
247
|
+
)
|
|
248
|
+
if has_skip_prefix and tier in ("A", "B"):
|
|
249
|
+
trace.append((
|
|
250
|
+
"rule.tie_break.highest_tier_wins",
|
|
251
|
+
f"tier={tier} + skip-prefix files present",
|
|
252
|
+
work_kind.value,
|
|
253
|
+
))
|
|
254
|
+
return work_kind, trace
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _resolve_mini_delivery_mode(
|
|
258
|
+
ac_set: List[str],
|
|
259
|
+
component_scope: Optional[str],
|
|
260
|
+
has_companion_dec: bool,
|
|
261
|
+
explain: bool,
|
|
262
|
+
) -> Tuple[str, List[Tuple[str, str, str]]]:
|
|
263
|
+
"""
|
|
264
|
+
Resolve ``mini`` → ``ultra_lean`` or ``mega_quick`` per US-0096
|
|
265
|
+
eligibility (L6 LOCKED).
|
|
266
|
+
"""
|
|
267
|
+
trace: List[Tuple[str, str, str]] = []
|
|
268
|
+
ac_count = len(ac_set)
|
|
269
|
+
single_component = component_scope is not None and "," not in str(component_scope)
|
|
270
|
+
eligible_mega_quick = (
|
|
271
|
+
ac_count <= MEGA_QUICK_MAX_AC
|
|
272
|
+
and not has_companion_dec
|
|
273
|
+
and single_component
|
|
274
|
+
)
|
|
275
|
+
if eligible_mega_quick:
|
|
276
|
+
if explain:
|
|
277
|
+
trace.append((
|
|
278
|
+
"rule.mini.mega_quick_eligible",
|
|
279
|
+
f"ac_count={ac_count}, dec={has_companion_dec}, "
|
|
280
|
+
f"component_scope={component_scope!r}",
|
|
281
|
+
"mega_quick",
|
|
282
|
+
))
|
|
283
|
+
return "mega_quick", trace
|
|
284
|
+
if explain:
|
|
285
|
+
trace.append((
|
|
286
|
+
"rule.mini.ultra_lean_fallback",
|
|
287
|
+
f"ac_count={ac_count}, dec={has_companion_dec}, "
|
|
288
|
+
f"single_component={single_component}",
|
|
289
|
+
"ultra_lean",
|
|
290
|
+
))
|
|
291
|
+
return "ultra_lean", trace
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Public API
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def classify_work_kind(
|
|
300
|
+
story_prose: str,
|
|
301
|
+
acceptance_criteria: List[str],
|
|
302
|
+
touched_file_hints: List[str],
|
|
303
|
+
component_scope: Optional[str] = None,
|
|
304
|
+
*,
|
|
305
|
+
has_companion_dec: bool = False,
|
|
306
|
+
explain: bool = False,
|
|
307
|
+
) -> WorkKindClassification:
|
|
308
|
+
"""
|
|
309
|
+
Classify a story into ``{doc, mini, code}`` and derive a recommended
|
|
310
|
+
delivery mode + phase plan.
|
|
311
|
+
|
|
312
|
+
Inputs are prose + AC set + touched-file hints (names-only, no content
|
|
313
|
+
reads) + component_scope string. Pure stdlib, no network, no ``.env``
|
|
314
|
+
reads, no LLM calls (Q3 LOCKED).
|
|
315
|
+
|
|
316
|
+
Parameters mirror R-0106 Q10 LOCKED signature. ``has_companion_dec``
|
|
317
|
+
and ``explain`` are keyword-only extensions surfaced for the
|
|
318
|
+
``--explain`` CLI trace + the ``mini`` → ``mega_quick`` eligibility
|
|
319
|
+
check.
|
|
320
|
+
"""
|
|
321
|
+
if not isinstance(touched_file_hints, list):
|
|
322
|
+
raise TypeError("touched_file_hints must be a list[str]")
|
|
323
|
+
|
|
324
|
+
# Reuse the canonical tier classifier (Q9 LOCKED import contract).
|
|
325
|
+
tier = classify_touched_files(touched_file_hints)
|
|
326
|
+
work_kind, trace = _resolve_work_kind_from_tier(
|
|
327
|
+
tier, touched_file_hints, explain
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Derive delivery mode + phase plan (DEC-0118 §1 mapping table).
|
|
331
|
+
if work_kind is WorkKind.DOC:
|
|
332
|
+
recommended_delivery_mode = "ultra_lean"
|
|
333
|
+
recommended_phase_plan = list(DOC_PHASE_PLAN)
|
|
334
|
+
if explain:
|
|
335
|
+
trace.append((
|
|
336
|
+
"rule.doc.lean_plan",
|
|
337
|
+
f"work_kind={work_kind.value}",
|
|
338
|
+
"ultra_lean + [intake, execute, release]",
|
|
339
|
+
))
|
|
340
|
+
elif work_kind is WorkKind.MINI:
|
|
341
|
+
recommended_delivery_mode, mini_trace = _resolve_mini_delivery_mode(
|
|
342
|
+
acceptance_criteria, component_scope, has_companion_dec, explain
|
|
343
|
+
)
|
|
344
|
+
trace.extend(mini_trace)
|
|
345
|
+
if recommended_delivery_mode == "mega_quick":
|
|
346
|
+
recommended_phase_plan = list(MINI_MEGA_QUICK_PHASE_PLAN)
|
|
347
|
+
else:
|
|
348
|
+
recommended_phase_plan = list(MINI_ULTRA_LEAN_PHASE_PLAN)
|
|
349
|
+
else: # WorkKind.CODE
|
|
350
|
+
recommended_delivery_mode = "standard"
|
|
351
|
+
recommended_phase_plan = list(CODE_STANDARD_PHASE_PLAN)
|
|
352
|
+
if explain:
|
|
353
|
+
trace.append((
|
|
354
|
+
"rule.code.standard",
|
|
355
|
+
f"work_kind={work_kind.value}",
|
|
356
|
+
"standard + full canonical lifecycle",
|
|
357
|
+
))
|
|
358
|
+
|
|
359
|
+
rationale = (
|
|
360
|
+
f"work_kind={work_kind.value} derived from "
|
|
361
|
+
f"classify_touched_files tier={tier}; "
|
|
362
|
+
f"recommended_delivery_mode={recommended_delivery_mode}; "
|
|
363
|
+
f"phase_plan={recommended_phase_plan}"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return WorkKindClassification(
|
|
367
|
+
work_kind=work_kind,
|
|
368
|
+
recommended_delivery_mode=recommended_delivery_mode,
|
|
369
|
+
recommended_phase_plan=recommended_phase_plan,
|
|
370
|
+
rationale=rationale,
|
|
371
|
+
evidence_refs=[], # names-only; populated by caller if needed
|
|
372
|
+
rule_trace=trace,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Self-test (AC-12) — exits 0 on success
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def self_test() -> int:
|
|
382
|
+
"""Built-in self-test (AC-12). Exits 0 on success, 1 on failure."""
|
|
383
|
+
failures: List[str] = []
|
|
384
|
+
|
|
385
|
+
# DOC route — only docs/ touched files → tier None → DOC → lean plan.
|
|
386
|
+
doc = classify_work_kind(
|
|
387
|
+
"Update README",
|
|
388
|
+
["AC-1 README updated"],
|
|
389
|
+
["docs/engineering/runbook.md", "docs/product/backlog.md"],
|
|
390
|
+
)
|
|
391
|
+
if doc.work_kind is not WorkKind.DOC:
|
|
392
|
+
failures.append(f"doc work_kind: expected DOC, got {doc.work_kind}")
|
|
393
|
+
if doc.recommended_phase_plan != list(DOC_PHASE_PLAN):
|
|
394
|
+
failures.append(
|
|
395
|
+
f"doc phase_plan: expected {DOC_PHASE_PLAN}, got {doc.recommended_phase_plan}"
|
|
396
|
+
)
|
|
397
|
+
if doc.recommended_delivery_mode != "ultra_lean":
|
|
398
|
+
failures.append(
|
|
399
|
+
f"doc delivery_mode: expected ultra_lean, got {doc.recommended_delivery_mode}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# MINI route — single env.example (tier B) → MINI → ultra_lean fallback
|
|
403
|
+
# (AC count > MEGA_QUICK_MAX_AC).
|
|
404
|
+
mini = classify_work_kind(
|
|
405
|
+
"Tweak nginx config",
|
|
406
|
+
[f"AC-{i}" for i in range(1, 5)], # 4 ACs > 3 → ultra_lean
|
|
407
|
+
[".env.example"],
|
|
408
|
+
component_scope="web",
|
|
409
|
+
)
|
|
410
|
+
if mini.work_kind is not WorkKind.MINI:
|
|
411
|
+
failures.append(f"mini work_kind: expected MINI, got {mini.work_kind}")
|
|
412
|
+
if mini.recommended_delivery_mode != "ultra_lean":
|
|
413
|
+
failures.append(
|
|
414
|
+
f"mini delivery_mode: expected ultra_lean, got {mini.recommended_delivery_mode}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# MINI → mega_quick when eligible (≤3 ACs, single component, no DEC).
|
|
418
|
+
mini_mq = classify_work_kind(
|
|
419
|
+
"Tiny fix",
|
|
420
|
+
["AC-1", "AC-2"],
|
|
421
|
+
[".env.example"],
|
|
422
|
+
component_scope="web",
|
|
423
|
+
)
|
|
424
|
+
if mini_mq.recommended_delivery_mode != "mega_quick":
|
|
425
|
+
failures.append(
|
|
426
|
+
f"mini mega_quick: expected mega_quick, got {mini_mq.recommended_delivery_mode}"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# CODE route — package.json (tier A) → CODE → standard.
|
|
430
|
+
code = classify_work_kind(
|
|
431
|
+
"New feature",
|
|
432
|
+
["AC-1", "AC-2", "AC-3", "AC-4"],
|
|
433
|
+
["package.json", "src/index.ts"],
|
|
434
|
+
)
|
|
435
|
+
if code.work_kind is not WorkKind.CODE:
|
|
436
|
+
failures.append(f"code work_kind: expected CODE, got {code.work_kind}")
|
|
437
|
+
if code.recommended_delivery_mode != "standard":
|
|
438
|
+
failures.append(
|
|
439
|
+
f"code delivery_mode: expected standard, got {code.recommended_delivery_mode}"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Tie-break — mixed docs/ + src/ → CODE (highest tier wins, Q1 LOCKED).
|
|
443
|
+
mixed = classify_work_kind(
|
|
444
|
+
"Feature + docs",
|
|
445
|
+
["AC-1"],
|
|
446
|
+
["docs/foo.md", "package.json"],
|
|
447
|
+
)
|
|
448
|
+
if mixed.work_kind is not WorkKind.CODE:
|
|
449
|
+
failures.append(
|
|
450
|
+
f"tie-break: expected CODE (highest tier wins), got {mixed.work_kind}"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# --explain emits rule_trace.
|
|
454
|
+
explained = classify_work_kind(
|
|
455
|
+
"Tiny fix",
|
|
456
|
+
["AC-1"],
|
|
457
|
+
[".env.example"],
|
|
458
|
+
component_scope="web",
|
|
459
|
+
explain=True,
|
|
460
|
+
)
|
|
461
|
+
if not explained.rule_trace:
|
|
462
|
+
failures.append("explain=True: rule_trace must be non-empty")
|
|
463
|
+
|
|
464
|
+
# Import boundary — classify_touched_files must be imported, not copied.
|
|
465
|
+
from work_kind_classify_lib import classify_touched_files as imported
|
|
466
|
+
if imported is not classify_touched_files:
|
|
467
|
+
failures.append("import boundary: classify_touched_files not reused")
|
|
468
|
+
|
|
469
|
+
if failures:
|
|
470
|
+
for f in failures:
|
|
471
|
+
print(f"[WORK_KIND_CLASSIFY_SELF_TEST_FAIL] {f}", file=sys.stderr)
|
|
472
|
+
return 1
|
|
473
|
+
print("[WORK_KIND_CLASSIFY_SELF_TEST_OK]")
|
|
474
|
+
return 0
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# CLI
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
483
|
+
p = argparse.ArgumentParser(
|
|
484
|
+
description="Work-kind classifier (US-0118 / DEC-0118)."
|
|
485
|
+
)
|
|
486
|
+
p.add_argument(
|
|
487
|
+
"--self-test",
|
|
488
|
+
action="store_true",
|
|
489
|
+
help="Run built-in self-test (AC-12).",
|
|
490
|
+
)
|
|
491
|
+
p.add_argument(
|
|
492
|
+
"--explain",
|
|
493
|
+
action="store_true",
|
|
494
|
+
help="Emit rule_trace in the JSON output (Q3 LOCKED).",
|
|
495
|
+
)
|
|
496
|
+
p.add_argument(
|
|
497
|
+
"--story-prose",
|
|
498
|
+
default="",
|
|
499
|
+
help="Story prose (names-only, no content reads).",
|
|
500
|
+
)
|
|
501
|
+
p.add_argument(
|
|
502
|
+
"--ac",
|
|
503
|
+
nargs="*",
|
|
504
|
+
default=[],
|
|
505
|
+
help="Acceptance criteria list.",
|
|
506
|
+
)
|
|
507
|
+
p.add_argument(
|
|
508
|
+
"--touched",
|
|
509
|
+
nargs="*",
|
|
510
|
+
default=[],
|
|
511
|
+
help="Touched file hints (names-only).",
|
|
512
|
+
)
|
|
513
|
+
p.add_argument(
|
|
514
|
+
"--component-scope",
|
|
515
|
+
default=None,
|
|
516
|
+
help="Component scope string (single component when set without comma).",
|
|
517
|
+
)
|
|
518
|
+
p.add_argument(
|
|
519
|
+
"--has-companion-dec",
|
|
520
|
+
action="store_true",
|
|
521
|
+
help="Story has a companion DEC (disables mega_quick eligibility).",
|
|
522
|
+
)
|
|
523
|
+
args = p.parse_args(argv)
|
|
524
|
+
|
|
525
|
+
if args.self_test:
|
|
526
|
+
return self_test()
|
|
527
|
+
|
|
528
|
+
result = classify_work_kind(
|
|
529
|
+
story_prose=args.story_prose,
|
|
530
|
+
acceptance_criteria=args.ac,
|
|
531
|
+
touched_file_hints=args.touched,
|
|
532
|
+
component_scope=args.component_scope,
|
|
533
|
+
has_companion_dec=args.has_companion_dec,
|
|
534
|
+
explain=args.explain,
|
|
535
|
+
)
|
|
536
|
+
import json
|
|
537
|
+
|
|
538
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
539
|
+
return 0
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
if __name__ == "__main__":
|
|
543
|
+
sys.exit(main())
|