@grifhinz/logics-manager 2.0.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/LICENSE +21 -0
- package/README.md +452 -0
- package/VERSION +1 -0
- package/logics_manager/__init__.py +5 -0
- package/logics_manager/__main__.py +9 -0
- package/logics_manager/assist.py +2211 -0
- package/logics_manager/audit.py +990 -0
- package/logics_manager/bootstrap.py +123 -0
- package/logics_manager/cli.py +183 -0
- package/logics_manager/config.py +251 -0
- package/logics_manager/doctor.py +127 -0
- package/logics_manager/flow.py +1449 -0
- package/logics_manager/index.py +142 -0
- package/logics_manager/lint.py +622 -0
- package/logics_manager/sync.py +604 -0
- package/package.json +162 -0
- package/pyproject.toml +15 -0
- package/scripts/logics-manager.py +15 -0
- package/scripts/npm/logics-manager.mjs +96 -0
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DocKind:
|
|
13
|
+
kind: str
|
|
14
|
+
directory: str
|
|
15
|
+
prefix: str
|
|
16
|
+
include_progress: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PlannedDoc:
|
|
21
|
+
ref: str
|
|
22
|
+
path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DOC_KINDS = {
|
|
26
|
+
"request": DocKind("request", "logics/request", "req", False),
|
|
27
|
+
"backlog": DocKind("backlog", "logics/backlog", "item", True),
|
|
28
|
+
"task": DocKind("task", "logics/tasks", "task", True),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
STATUS_BY_KIND_DEFAULT = {
|
|
32
|
+
"request": "Draft",
|
|
33
|
+
"backlog": "Ready",
|
|
34
|
+
"task": "Ready",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _split_titles(raw_titles: list[str]) -> list[str]:
|
|
39
|
+
titles = [title.strip() for title in raw_titles if title and title.strip()]
|
|
40
|
+
if not titles:
|
|
41
|
+
raise SystemExit("Provide at least one non-empty --title value.")
|
|
42
|
+
return titles
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _slugify(text: str) -> str:
|
|
46
|
+
cleaned = "".join(ch.lower() if ch.isalnum() else "_" for ch in text)
|
|
47
|
+
cleaned = "_".join(part for part in cleaned.split("_") if part)
|
|
48
|
+
return cleaned or "request"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolved_from_version(repo_root: Path, from_version: str | None) -> str:
|
|
52
|
+
if from_version:
|
|
53
|
+
return from_version
|
|
54
|
+
package_json = repo_root / "package.json"
|
|
55
|
+
if not package_json.is_file():
|
|
56
|
+
return "1.0.0"
|
|
57
|
+
try:
|
|
58
|
+
payload = json.loads(package_json.read_text(encoding="utf-8"))
|
|
59
|
+
except Exception:
|
|
60
|
+
return "1.0.0"
|
|
61
|
+
version = payload.get("version") if isinstance(payload, dict) else None
|
|
62
|
+
return str(version).strip() if version else "1.0.0"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_repo_root(start: Path) -> Path:
|
|
66
|
+
current = start.resolve()
|
|
67
|
+
for candidate in [current, *current.parents]:
|
|
68
|
+
if (candidate / "logics").is_dir():
|
|
69
|
+
return candidate
|
|
70
|
+
raise SystemExit("Could not locate repo root (missing 'logics/' directory). Run from inside the repo.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _plan_doc(repo_root: Path, directory: str, prefix: str, title: str, dry_run: bool = False) -> PlannedDoc:
|
|
74
|
+
target_dir = repo_root / directory
|
|
75
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
slug = _slugify(title)
|
|
77
|
+
highest = -1
|
|
78
|
+
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)_.*\.md$")
|
|
79
|
+
for path in target_dir.glob(f"{prefix}_*.md"):
|
|
80
|
+
match = pattern.match(path.name)
|
|
81
|
+
if match:
|
|
82
|
+
highest = max(highest, int(match.group(1)))
|
|
83
|
+
ref = f"{prefix}_{highest + 1:03d}_{slug}"
|
|
84
|
+
path = target_dir / f"{ref}.md"
|
|
85
|
+
return PlannedDoc(ref=ref, path=path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_refs(text: str, prefix: str) -> list[str]:
|
|
89
|
+
pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
|
|
90
|
+
return sorted({match.group(0) for match in pattern.finditer(text)})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _strip_mermaid_blocks(text: str) -> str:
|
|
94
|
+
return re.sub(r"```mermaid\s*\n.*?\n```", "", text, flags=re.DOTALL)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_doc_path(repo_root: Path, kind: DocKind, ref: str) -> Path | None:
|
|
98
|
+
path = repo_root / kind.directory / f"{ref}.md"
|
|
99
|
+
return path if path.is_file() else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _append_section_bullets(path: Path, heading: str, bullets: list[str], dry_run: bool) -> None:
|
|
103
|
+
if dry_run:
|
|
104
|
+
return
|
|
105
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
106
|
+
start_idx = None
|
|
107
|
+
for idx, line in enumerate(lines):
|
|
108
|
+
if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
|
|
109
|
+
start_idx = idx + 1
|
|
110
|
+
break
|
|
111
|
+
if start_idx is None:
|
|
112
|
+
lines.extend(["", f"# {heading}", *[f"- {bullet}" for bullet in bullets]])
|
|
113
|
+
else:
|
|
114
|
+
insert_at = start_idx
|
|
115
|
+
while insert_at < len(lines) and lines[insert_at].strip().startswith("- "):
|
|
116
|
+
insert_at += 1
|
|
117
|
+
existing = {line.strip() for line in lines[start_idx:insert_at] if line.strip().startswith("- ")}
|
|
118
|
+
for bullet in bullets:
|
|
119
|
+
rendered = f"- {bullet}"
|
|
120
|
+
if rendered not in existing:
|
|
121
|
+
lines.insert(insert_at, rendered)
|
|
122
|
+
insert_at += 1
|
|
123
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mark_section_checkboxes_done(path: Path, heading: str, dry_run: bool) -> None:
|
|
127
|
+
if dry_run:
|
|
128
|
+
return
|
|
129
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
130
|
+
start_idx = None
|
|
131
|
+
for idx, line in enumerate(lines):
|
|
132
|
+
if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
|
|
133
|
+
start_idx = idx + 1
|
|
134
|
+
break
|
|
135
|
+
if start_idx is None:
|
|
136
|
+
return
|
|
137
|
+
changed = False
|
|
138
|
+
for idx in range(start_idx, len(lines)):
|
|
139
|
+
line = lines[idx]
|
|
140
|
+
if line.startswith("# "):
|
|
141
|
+
break
|
|
142
|
+
if "- [ ]" in line:
|
|
143
|
+
lines[idx] = line.replace("- [ ]", "- [x]", 1)
|
|
144
|
+
changed = True
|
|
145
|
+
if changed:
|
|
146
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _collect_docs_linking_ref(repo_root: Path, kind: DocKind, ref: str) -> list[Path]:
|
|
150
|
+
directory = repo_root / kind.directory
|
|
151
|
+
linked: list[Path] = []
|
|
152
|
+
if not directory.is_dir():
|
|
153
|
+
return linked
|
|
154
|
+
for path in sorted(directory.glob("*.md")):
|
|
155
|
+
if ref in path.read_text(encoding="utf-8"):
|
|
156
|
+
linked.append(path)
|
|
157
|
+
return linked
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _close_doc(path: Path, kind: DocKind, dry_run: bool) -> None:
|
|
161
|
+
if dry_run:
|
|
162
|
+
return
|
|
163
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
164
|
+
updated: list[str] = []
|
|
165
|
+
saw_status = False
|
|
166
|
+
saw_progress = False
|
|
167
|
+
for line in lines:
|
|
168
|
+
if line.startswith("> Status:"):
|
|
169
|
+
updated.append("> Status: Done")
|
|
170
|
+
saw_status = True
|
|
171
|
+
elif kind.include_progress and line.startswith("> Progress:"):
|
|
172
|
+
updated.append("> Progress: 100%")
|
|
173
|
+
saw_progress = True
|
|
174
|
+
else:
|
|
175
|
+
updated.append(line)
|
|
176
|
+
if not saw_status:
|
|
177
|
+
updated.insert(1, "> Status: Done")
|
|
178
|
+
if kind.include_progress and not saw_progress:
|
|
179
|
+
insert_at = 2 if saw_status else 3
|
|
180
|
+
updated.insert(insert_at, "> Progress: 100%")
|
|
181
|
+
path.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_doc_done(path: Path, kind: DocKind) -> bool:
|
|
185
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
186
|
+
status_value = next((line.split(":", 1)[1].strip() for line in lines if line.startswith("> Status:")), None)
|
|
187
|
+
if status_value is not None and " ".join(status_value.split()).lower() in {"done", "archived"}:
|
|
188
|
+
return True
|
|
189
|
+
if kind.include_progress:
|
|
190
|
+
progress_value = next((line.split(":", 1)[1].strip() for line in lines if line.startswith("> Progress:")), None)
|
|
191
|
+
if progress_value == "100%":
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _add_common_doc_args(parser: argparse.ArgumentParser, kind: str) -> None:
|
|
197
|
+
parser.add_argument("--from-version")
|
|
198
|
+
parser.add_argument("--understanding", default="90%")
|
|
199
|
+
parser.add_argument("--confidence", default="85%")
|
|
200
|
+
parser.add_argument("--status", default=STATUS_BY_KIND_DEFAULT[kind])
|
|
201
|
+
parser.add_argument("--complexity", default="Medium")
|
|
202
|
+
parser.add_argument("--theme", default="General")
|
|
203
|
+
if DOC_KINDS[kind].include_progress:
|
|
204
|
+
parser.add_argument("--progress", default="0%")
|
|
205
|
+
else:
|
|
206
|
+
parser.add_argument("--progress", default="")
|
|
207
|
+
if kind in {"backlog", "task"}:
|
|
208
|
+
parser.add_argument("--auto-create-product-brief", action="store_true")
|
|
209
|
+
parser.add_argument("--auto-create-adr", action="store_true")
|
|
210
|
+
parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
211
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _build_native_request_doc(repo_root: Path, planned_ref: str, title: str, args: argparse.Namespace) -> str:
|
|
215
|
+
from_version = _resolved_from_version(repo_root, getattr(args, "from_version", None))
|
|
216
|
+
fixture_mode = bool(getattr(args, "fixture", False))
|
|
217
|
+
context = [
|
|
218
|
+
"Generated locally by logics-manager.",
|
|
219
|
+
"No manual skills bootstrap or bridge editing is required.",
|
|
220
|
+
]
|
|
221
|
+
if fixture_mode:
|
|
222
|
+
context.append("Synthetic fixture for request generation smoke tests.")
|
|
223
|
+
references = [
|
|
224
|
+
"`logics_manager/flow.py`",
|
|
225
|
+
"`logics_manager/assist.py`",
|
|
226
|
+
"`python_tests/test_logics_manager_cli.py`",
|
|
227
|
+
]
|
|
228
|
+
return "\n".join(
|
|
229
|
+
[
|
|
230
|
+
f"## {planned_ref} - {title}",
|
|
231
|
+
f"> From version: {from_version}",
|
|
232
|
+
"> Schema version: 1.0",
|
|
233
|
+
"> Status: Draft",
|
|
234
|
+
"> Understanding: 90%",
|
|
235
|
+
"> Confidence: 85%",
|
|
236
|
+
"> Complexity: Medium",
|
|
237
|
+
"> Theme: Operator workflow",
|
|
238
|
+
"> Reminder: Update status/understanding/confidence and linked backlog/task references when you edit this doc.",
|
|
239
|
+
"",
|
|
240
|
+
"# Needs",
|
|
241
|
+
f"- Deliver a bounded request for {title.lower()}.",
|
|
242
|
+
"",
|
|
243
|
+
"# Context",
|
|
244
|
+
*[f"- {item}" for item in context],
|
|
245
|
+
"",
|
|
246
|
+
"# Acceptance criteria",
|
|
247
|
+
f"- AC1: The request states the bounded need for {title.lower()}.",
|
|
248
|
+
"- AC2: Scope boundaries and operator impact are explicit.",
|
|
249
|
+
"- AC3: The request is ready to be promoted into a backlog slice.",
|
|
250
|
+
"",
|
|
251
|
+
"# Definition of Ready (DoR)",
|
|
252
|
+
"- [ ] Problem statement is explicit and user impact is clear.",
|
|
253
|
+
"- [ ] Scope boundaries (in/out) are explicit.",
|
|
254
|
+
"- [ ] Acceptance criteria are testable.",
|
|
255
|
+
"- [ ] Dependencies and known risks are listed.",
|
|
256
|
+
"",
|
|
257
|
+
"# Companion docs",
|
|
258
|
+
"- Product brief(s): (none yet)",
|
|
259
|
+
"- Architecture decision(s): (none yet)",
|
|
260
|
+
"",
|
|
261
|
+
"# References",
|
|
262
|
+
*[f"- {item}" for item in references],
|
|
263
|
+
"",
|
|
264
|
+
"# AI Context",
|
|
265
|
+
f"- Summary: Draft a bounded request for {title.lower()}.",
|
|
266
|
+
"- Keywords: request-draft, logics-manager, python runtime, bundled CLI",
|
|
267
|
+
"- Use when: You need a new bounded request doc for the Logics workflow.",
|
|
268
|
+
"- Skip when: The work already has an existing request or should go straight to a backlog slice.",
|
|
269
|
+
"",
|
|
270
|
+
"# Backlog",
|
|
271
|
+
"- none",
|
|
272
|
+
"",
|
|
273
|
+
]
|
|
274
|
+
).rstrip() + "\n"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_native_backlog_doc(
|
|
278
|
+
repo_root: Path,
|
|
279
|
+
planned_ref: str,
|
|
280
|
+
title: str,
|
|
281
|
+
args: argparse.Namespace,
|
|
282
|
+
*,
|
|
283
|
+
request_ref: str | None = None,
|
|
284
|
+
product_refs: list[str] | None = None,
|
|
285
|
+
architecture_refs: list[str] | None = None,
|
|
286
|
+
) -> str:
|
|
287
|
+
from_version = _resolved_from_version(repo_root, getattr(args, "from_version", None))
|
|
288
|
+
product_refs = product_refs or []
|
|
289
|
+
architecture_refs = architecture_refs or []
|
|
290
|
+
product_line = ", ".join(f"`{ref}`" for ref in product_refs) if product_refs else "(none yet)"
|
|
291
|
+
architecture_line = ", ".join(f"`{ref}`" for ref in architecture_refs) if architecture_refs else "(none yet)"
|
|
292
|
+
request_line = f"`{request_ref}`" if request_ref else "(to be linked)"
|
|
293
|
+
acceptance = [
|
|
294
|
+
f"AC1: The backlog slice stays bounded for {title.lower()}.",
|
|
295
|
+
"AC2: The backlog slice is reviewable and promotable into a task.",
|
|
296
|
+
]
|
|
297
|
+
return "\n".join(
|
|
298
|
+
[
|
|
299
|
+
f"## {planned_ref} - {title}",
|
|
300
|
+
f"> From version: {from_version}",
|
|
301
|
+
"> Schema version: 1.0",
|
|
302
|
+
f"> Status: {getattr(args, 'status', 'Ready')}",
|
|
303
|
+
f"> Understanding: {getattr(args, 'understanding', '90%')}",
|
|
304
|
+
f"> Confidence: {getattr(args, 'confidence', '85%')}",
|
|
305
|
+
f"> Progress: {getattr(args, 'progress', '0%')}",
|
|
306
|
+
f"> Complexity: {getattr(args, 'complexity', 'Medium')}",
|
|
307
|
+
f"> Theme: {getattr(args, 'theme', 'General')}",
|
|
308
|
+
"> Reminder: Update status/understanding/confidence/progress and linked request/task references when you edit this doc.",
|
|
309
|
+
"",
|
|
310
|
+
"# Problem",
|
|
311
|
+
f"- Deliver a bounded backlog slice for {title.lower()}.",
|
|
312
|
+
"",
|
|
313
|
+
"# Scope",
|
|
314
|
+
"- In:",
|
|
315
|
+
" - one coherent delivery slice from the operator request.",
|
|
316
|
+
"- Out:",
|
|
317
|
+
" - unrelated sibling slices.",
|
|
318
|
+
"",
|
|
319
|
+
"# Acceptance criteria",
|
|
320
|
+
*[f"- {item}" for item in acceptance],
|
|
321
|
+
"",
|
|
322
|
+
"# AC Traceability",
|
|
323
|
+
"- request-AC1 -> This backlog slice. Proof: bounded delivery slice.",
|
|
324
|
+
"- request-AC2 -> This backlog slice. Proof: promotable backlog item.",
|
|
325
|
+
"",
|
|
326
|
+
"# Decision framing",
|
|
327
|
+
"- Product framing: Not needed",
|
|
328
|
+
"- Architecture framing: Not needed",
|
|
329
|
+
"",
|
|
330
|
+
"# Links",
|
|
331
|
+
f"- Product brief(s): {product_line}",
|
|
332
|
+
f"- Architecture decision(s): {architecture_line}",
|
|
333
|
+
f"- Request: {request_line}",
|
|
334
|
+
"- Primary task(s): (none yet)",
|
|
335
|
+
"",
|
|
336
|
+
"# AI Context",
|
|
337
|
+
f"- Summary: {title}",
|
|
338
|
+
f"- Keywords: backlog, promote, slice, {title.lower()}",
|
|
339
|
+
f"- Use when: You need a bounded backlog item for {title}.",
|
|
340
|
+
"- Skip when: The change should go straight to implementation detail.",
|
|
341
|
+
"",
|
|
342
|
+
"# Priority",
|
|
343
|
+
"- Impact:",
|
|
344
|
+
"- Urgency:",
|
|
345
|
+
"",
|
|
346
|
+
"# Notes",
|
|
347
|
+
"- Generated locally by logics-manager.",
|
|
348
|
+
"",
|
|
349
|
+
]
|
|
350
|
+
).rstrip() + "\n"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_native_task_doc(
|
|
354
|
+
repo_root: Path,
|
|
355
|
+
planned_ref: str,
|
|
356
|
+
title: str,
|
|
357
|
+
args: argparse.Namespace,
|
|
358
|
+
*,
|
|
359
|
+
backlog_ref: str | None = None,
|
|
360
|
+
request_refs: list[str] | None = None,
|
|
361
|
+
product_refs: list[str] | None = None,
|
|
362
|
+
architecture_refs: list[str] | None = None,
|
|
363
|
+
) -> str:
|
|
364
|
+
from_version = _resolved_from_version(repo_root, getattr(args, "from_version", None))
|
|
365
|
+
request_refs = request_refs or []
|
|
366
|
+
product_refs = product_refs or []
|
|
367
|
+
architecture_refs = architecture_refs or []
|
|
368
|
+
backlog_line = f"`{backlog_ref}`" if backlog_ref else "(to be linked)"
|
|
369
|
+
request_line = ", ".join(f"`{ref}`" for ref in request_refs) if request_refs else "(none yet)"
|
|
370
|
+
product_line = ", ".join(f"`{ref}`" for ref in product_refs) if product_refs else "(none yet)"
|
|
371
|
+
architecture_line = ", ".join(f"`{ref}`" for ref in architecture_refs) if architecture_refs else "(none yet)"
|
|
372
|
+
return "\n".join(
|
|
373
|
+
[
|
|
374
|
+
f"## {planned_ref} - {title}",
|
|
375
|
+
f"> From version: {from_version}",
|
|
376
|
+
"> Schema version: 1.0",
|
|
377
|
+
f"> Status: {getattr(args, 'status', 'Ready')}",
|
|
378
|
+
f"> Understanding: {getattr(args, 'understanding', '90%')}",
|
|
379
|
+
f"> Confidence: {getattr(args, 'confidence', '85%')}",
|
|
380
|
+
f"> Progress: {getattr(args, 'progress', '0%')}",
|
|
381
|
+
f"> Complexity: {getattr(args, 'complexity', 'Medium')}",
|
|
382
|
+
f"> Theme: {getattr(args, 'theme', 'General')}",
|
|
383
|
+
"> Reminder: Update status/understanding/confidence/progress and linked request/backlog references when you edit this doc.",
|
|
384
|
+
"",
|
|
385
|
+
"# Context",
|
|
386
|
+
f"- Execute the bounded delivery slice for {title}.",
|
|
387
|
+
"",
|
|
388
|
+
"# Plan",
|
|
389
|
+
"- [ ] 1. Confirm scope, dependencies, and linked acceptance criteria.",
|
|
390
|
+
"- [ ] 2. Implement the next coherent delivery wave.",
|
|
391
|
+
"- [ ] 3. Checkpoint the wave in a commit-ready state, validate it, and update the linked Logics docs.",
|
|
392
|
+
"- [ ] GATE: do not close a wave or step until the relevant automated tests and quality checks have been run successfully.",
|
|
393
|
+
"",
|
|
394
|
+
"# Backlog",
|
|
395
|
+
f"- {backlog_line}",
|
|
396
|
+
"",
|
|
397
|
+
"# Definition of Done (DoD)",
|
|
398
|
+
"- [ ] Code is implemented and reviewed.",
|
|
399
|
+
"- [ ] Validation passes.",
|
|
400
|
+
"- [ ] Linked docs are synchronized.",
|
|
401
|
+
"",
|
|
402
|
+
"# Validation",
|
|
403
|
+
"- Run `python3 -m logics_manager lint --require-status`.",
|
|
404
|
+
"- Run the task-specific automated tests.",
|
|
405
|
+
"",
|
|
406
|
+
"# Report",
|
|
407
|
+
"- Implementation complete.",
|
|
408
|
+
"",
|
|
409
|
+
"# AI Context",
|
|
410
|
+
f"- Summary: Implement {title.lower()}.",
|
|
411
|
+
"- Keywords: task, implementation, backlog, runtime, python",
|
|
412
|
+
"- Use when: You need a bounded implementation task for a backlog item.",
|
|
413
|
+
"- Skip when: The work is still at the request or backlog shaping stage.",
|
|
414
|
+
"",
|
|
415
|
+
"# Links",
|
|
416
|
+
f"- Request: {request_line}",
|
|
417
|
+
f"- Product brief(s): {product_line}",
|
|
418
|
+
f"- Architecture decision(s): {architecture_line}",
|
|
419
|
+
"",
|
|
420
|
+
]
|
|
421
|
+
).rstrip() + "\n"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _extract_doc_title(path: Path) -> str:
|
|
425
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
426
|
+
if line.startswith("## "):
|
|
427
|
+
payload = line.removeprefix("## ").strip()
|
|
428
|
+
if " - " in payload:
|
|
429
|
+
return payload.split(" - ", 1)[1].strip()
|
|
430
|
+
return payload
|
|
431
|
+
return path.stem
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _section_lines(lines: list[str], heading: str) -> list[str]:
|
|
435
|
+
target = heading.strip().lower()
|
|
436
|
+
start_idx = None
|
|
437
|
+
for idx, line in enumerate(lines):
|
|
438
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
439
|
+
start_idx = idx + 1
|
|
440
|
+
break
|
|
441
|
+
if start_idx is None:
|
|
442
|
+
return []
|
|
443
|
+
out: list[str] = []
|
|
444
|
+
for idx in range(start_idx, len(lines)):
|
|
445
|
+
line = lines[idx]
|
|
446
|
+
if line.startswith("# "):
|
|
447
|
+
break
|
|
448
|
+
out.append(line)
|
|
449
|
+
return out
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _bullet_values(lines: list[str]) -> list[str]:
|
|
453
|
+
values: list[str] = []
|
|
454
|
+
for line in lines:
|
|
455
|
+
stripped = line.strip()
|
|
456
|
+
if stripped.startswith("- "):
|
|
457
|
+
value = stripped[2:].strip()
|
|
458
|
+
if value:
|
|
459
|
+
values.append(value)
|
|
460
|
+
return values
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _next_backlog_ref(repo_root: Path, title: str) -> str:
|
|
464
|
+
directory = repo_root / "logics" / "backlog"
|
|
465
|
+
highest = 0
|
|
466
|
+
if directory.is_dir():
|
|
467
|
+
for path in directory.glob("item_*.md"):
|
|
468
|
+
stem = path.stem
|
|
469
|
+
if stem.startswith("item_"):
|
|
470
|
+
parts = stem.split("_", 2)
|
|
471
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
472
|
+
highest = max(highest, int(parts[1]))
|
|
473
|
+
return f"item_{highest + 1:03d}_{_slugify(title)}"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _next_task_ref(repo_root: Path, title: str) -> str:
|
|
477
|
+
directory = repo_root / "logics" / "tasks"
|
|
478
|
+
highest = 0
|
|
479
|
+
if directory.is_dir():
|
|
480
|
+
for path in directory.glob("task_*.md"):
|
|
481
|
+
stem = path.stem
|
|
482
|
+
if stem.startswith("task_"):
|
|
483
|
+
parts = stem.split("_", 2)
|
|
484
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
485
|
+
highest = max(highest, int(parts[1]))
|
|
486
|
+
return f"task_{highest + 1:03d}_{_slugify(title)}"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _next_product_ref(repo_root: Path, title: str) -> str:
|
|
490
|
+
directory = repo_root / "logics" / "product"
|
|
491
|
+
highest = 0
|
|
492
|
+
if directory.is_dir():
|
|
493
|
+
for path in directory.glob("prod_*.md"):
|
|
494
|
+
stem = path.stem
|
|
495
|
+
if stem.startswith("prod_"):
|
|
496
|
+
parts = stem.split("_", 2)
|
|
497
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
498
|
+
highest = max(highest, int(parts[1]))
|
|
499
|
+
return f"prod_{highest + 1:03d}_{_slugify(title)}"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _next_adr_ref(repo_root: Path, title: str) -> str:
|
|
503
|
+
directory = repo_root / "logics" / "architecture"
|
|
504
|
+
highest = 0
|
|
505
|
+
if directory.is_dir():
|
|
506
|
+
for path in directory.glob("adr_*.md"):
|
|
507
|
+
stem = path.stem
|
|
508
|
+
if stem.startswith("adr_"):
|
|
509
|
+
parts = stem.split("_", 2)
|
|
510
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
511
|
+
highest = max(highest, int(parts[1]))
|
|
512
|
+
return f"adr_{highest + 1:03d}_{_slugify(title)}"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _append_doc_section_bullets(path: Path, heading: str, bullets: list[str], *, dry_run: bool) -> None:
|
|
516
|
+
if dry_run:
|
|
517
|
+
return
|
|
518
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
519
|
+
for idx, line in enumerate(lines):
|
|
520
|
+
if line.startswith("# ") and line[2:].strip().lower() == heading.strip().lower():
|
|
521
|
+
insert_at = idx + 1
|
|
522
|
+
while insert_at < len(lines) and lines[insert_at].strip().startswith("- "):
|
|
523
|
+
insert_at += 1
|
|
524
|
+
existing = {line.strip() for line in lines[idx + 1 : insert_at] if line.strip().startswith("- ")}
|
|
525
|
+
for bullet in bullets:
|
|
526
|
+
rendered = f"- {bullet}"
|
|
527
|
+
if rendered not in existing:
|
|
528
|
+
lines.insert(insert_at, rendered)
|
|
529
|
+
insert_at += 1
|
|
530
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
531
|
+
return
|
|
532
|
+
lines.extend(["", f"# {heading}", *[f"- {bullet}" for bullet in bullets]])
|
|
533
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _build_native_product_brief(
|
|
537
|
+
repo_root: Path,
|
|
538
|
+
title: str,
|
|
539
|
+
*,
|
|
540
|
+
request_ref: str | None = None,
|
|
541
|
+
backlog_ref: str | None = None,
|
|
542
|
+
task_ref: str | None = None,
|
|
543
|
+
architecture_refs: list[str] | None = None,
|
|
544
|
+
) -> tuple[str, str]:
|
|
545
|
+
ref = _next_product_ref(repo_root, title)
|
|
546
|
+
architecture_refs = architecture_refs or []
|
|
547
|
+
related_request = f"`{request_ref}`" if request_ref else "(none yet)"
|
|
548
|
+
related_backlog = f"`{backlog_ref}`" if backlog_ref else "(none yet)"
|
|
549
|
+
related_task = f"`{task_ref}`" if task_ref else "(none yet)"
|
|
550
|
+
related_architecture = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
|
|
551
|
+
content = "\n".join(
|
|
552
|
+
[
|
|
553
|
+
f"## {ref} - {title}",
|
|
554
|
+
f"> Date: {date.today().isoformat()}",
|
|
555
|
+
"> Status: Proposed",
|
|
556
|
+
f"> Related request: {related_request}",
|
|
557
|
+
f"> Related backlog: {related_backlog}",
|
|
558
|
+
f"> Related task: {related_task}",
|
|
559
|
+
f"> Related architecture: {related_architecture}",
|
|
560
|
+
"> Reminder: Update status, linked refs, scope, decisions, success signals, and open questions when you edit this doc.",
|
|
561
|
+
"",
|
|
562
|
+
"# Overview",
|
|
563
|
+
f"Logics should keep a single, predictable product surface for {title.lower()}.",
|
|
564
|
+
"",
|
|
565
|
+
"# Goals",
|
|
566
|
+
"- Keep the operator experience bounded and easy to reason about.",
|
|
567
|
+
"- Preserve the CLI as the canonical workflow entrypoint.",
|
|
568
|
+
"",
|
|
569
|
+
"# Non-goals",
|
|
570
|
+
"- Rebuilding the VS Code plugin UI in this document.",
|
|
571
|
+
"- Adding a remote runtime boundary.",
|
|
572
|
+
"",
|
|
573
|
+
"# Scope and guardrails",
|
|
574
|
+
"- In: user-facing workflow shape, CLI contract, and migration boundaries.",
|
|
575
|
+
"- Out: unrelated UI redesign or cloud-hosted orchestration.",
|
|
576
|
+
"",
|
|
577
|
+
"# Key product decisions",
|
|
578
|
+
"- Keep the runtime integrated and local.",
|
|
579
|
+
"- Keep assistant-facing instructions derived from the runtime.",
|
|
580
|
+
"",
|
|
581
|
+
"# Success signals",
|
|
582
|
+
"- The change can be used without extra manual setup.",
|
|
583
|
+
"- The product can be explained from a single reference surface.",
|
|
584
|
+
"",
|
|
585
|
+
"# References",
|
|
586
|
+
f"- Product back-reference: {related_backlog}",
|
|
587
|
+
f"- Task back-reference: {related_task}",
|
|
588
|
+
"",
|
|
589
|
+
]
|
|
590
|
+
).rstrip() + "\n"
|
|
591
|
+
return ref, content
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _build_native_adr(
|
|
595
|
+
repo_root: Path,
|
|
596
|
+
title: str,
|
|
597
|
+
*,
|
|
598
|
+
request_ref: str | None = None,
|
|
599
|
+
backlog_ref: str | None = None,
|
|
600
|
+
task_ref: str | None = None,
|
|
601
|
+
) -> tuple[str, str]:
|
|
602
|
+
ref = _next_adr_ref(repo_root, title)
|
|
603
|
+
related_request = f"`{request_ref}`" if request_ref else "(none yet)"
|
|
604
|
+
related_backlog = f"`{backlog_ref}`" if backlog_ref else "(none yet)"
|
|
605
|
+
related_task = f"`{task_ref}`" if task_ref else "(none yet)"
|
|
606
|
+
content = "\n".join(
|
|
607
|
+
[
|
|
608
|
+
f"## {ref} - {title}",
|
|
609
|
+
f"> Date: {date.today().isoformat()}",
|
|
610
|
+
"> Status: Proposed",
|
|
611
|
+
f"> Related request: {related_request}",
|
|
612
|
+
f"> Related backlog: {related_backlog}",
|
|
613
|
+
f"> Related task: {related_task}",
|
|
614
|
+
"> Reminder: Update status, linked refs, decision rationale, consequences, and follow-up work when you edit this doc.",
|
|
615
|
+
"",
|
|
616
|
+
"# Overview",
|
|
617
|
+
f"This ADR captures the native direction for {title.lower()}.",
|
|
618
|
+
"",
|
|
619
|
+
"# Context",
|
|
620
|
+
"- The runtime is being consolidated into the main repo.",
|
|
621
|
+
"- Legacy skill/bootstrap boundaries are being retired.",
|
|
622
|
+
"",
|
|
623
|
+
"# Decision",
|
|
624
|
+
"- Prefer a native Python runtime with a minimal plugin shell.",
|
|
625
|
+
"",
|
|
626
|
+
"# Consequences",
|
|
627
|
+
"- The CLI becomes the primary operational surface.",
|
|
628
|
+
"- Companion docs can be generated from the same runtime contract.",
|
|
629
|
+
"",
|
|
630
|
+
"# References",
|
|
631
|
+
f"- Related request: {related_request}",
|
|
632
|
+
f"- Related backlog: {related_backlog}",
|
|
633
|
+
f"- Related task: {related_task}",
|
|
634
|
+
"",
|
|
635
|
+
]
|
|
636
|
+
).rstrip() + "\n"
|
|
637
|
+
return ref, content
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _create_native_companion_docs(
|
|
641
|
+
repo_root: Path,
|
|
642
|
+
title: str,
|
|
643
|
+
*,
|
|
644
|
+
request_ref: str | None = None,
|
|
645
|
+
backlog_ref: str | None = None,
|
|
646
|
+
task_ref: str | None = None,
|
|
647
|
+
args: argparse.Namespace,
|
|
648
|
+
) -> tuple[list[str], list[str]]:
|
|
649
|
+
created_product_refs: list[str] = []
|
|
650
|
+
created_architecture_refs: list[str] = []
|
|
651
|
+
|
|
652
|
+
if getattr(args, "auto_create_adr", False):
|
|
653
|
+
adr_ref, adr_content = _build_native_adr(
|
|
654
|
+
repo_root,
|
|
655
|
+
title,
|
|
656
|
+
request_ref=request_ref,
|
|
657
|
+
backlog_ref=backlog_ref,
|
|
658
|
+
task_ref=task_ref,
|
|
659
|
+
)
|
|
660
|
+
adr_path = repo_root / "logics" / "architecture" / f"{adr_ref}.md"
|
|
661
|
+
if not args.dry_run:
|
|
662
|
+
adr_path.parent.mkdir(parents=True, exist_ok=True)
|
|
663
|
+
adr_path.write_text(adr_content, encoding="utf-8")
|
|
664
|
+
created_architecture_refs.append(adr_ref)
|
|
665
|
+
|
|
666
|
+
if getattr(args, "auto_create_product_brief", False):
|
|
667
|
+
product_ref, product_content = _build_native_product_brief(
|
|
668
|
+
repo_root,
|
|
669
|
+
title,
|
|
670
|
+
request_ref=request_ref,
|
|
671
|
+
backlog_ref=backlog_ref,
|
|
672
|
+
task_ref=task_ref,
|
|
673
|
+
architecture_refs=created_architecture_refs,
|
|
674
|
+
)
|
|
675
|
+
product_path = repo_root / "logics" / "product" / f"{product_ref}.md"
|
|
676
|
+
if not args.dry_run:
|
|
677
|
+
product_path.parent.mkdir(parents=True, exist_ok=True)
|
|
678
|
+
product_path.write_text(product_content, encoding="utf-8")
|
|
679
|
+
created_product_refs.append(product_ref)
|
|
680
|
+
|
|
681
|
+
return created_product_refs, created_architecture_refs
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _resolve_workflow_refs_for_companion(
|
|
685
|
+
source_ref: str | None,
|
|
686
|
+
*,
|
|
687
|
+
request_ref: str | None = None,
|
|
688
|
+
backlog_ref: str | None = None,
|
|
689
|
+
task_ref: str | None = None,
|
|
690
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
691
|
+
resolved_request = request_ref
|
|
692
|
+
resolved_backlog = backlog_ref
|
|
693
|
+
resolved_task = task_ref
|
|
694
|
+
|
|
695
|
+
if source_ref:
|
|
696
|
+
if source_ref.startswith(f"{DOC_KINDS['request'].prefix}_"):
|
|
697
|
+
resolved_request = source_ref
|
|
698
|
+
elif source_ref.startswith(f"{DOC_KINDS['backlog'].prefix}_"):
|
|
699
|
+
resolved_backlog = source_ref
|
|
700
|
+
elif source_ref.startswith(f"{DOC_KINDS['task'].prefix}_"):
|
|
701
|
+
resolved_task = source_ref
|
|
702
|
+
else:
|
|
703
|
+
raise SystemExit(
|
|
704
|
+
"Unsupported --source-ref value. Expected a request, backlog, or task ref such as "
|
|
705
|
+
"`req_001_demo`, `item_001_demo`, or `task_001_demo`."
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
return resolved_request, resolved_backlog, resolved_task
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _build_native_backlog_from_request(
|
|
712
|
+
repo_root: Path,
|
|
713
|
+
request_path: Path,
|
|
714
|
+
title: str | None = None,
|
|
715
|
+
*,
|
|
716
|
+
product_refs: list[str] | None = None,
|
|
717
|
+
architecture_refs: list[str] | None = None,
|
|
718
|
+
) -> tuple[str, str]:
|
|
719
|
+
request_lines = request_path.read_text(encoding="utf-8").splitlines()
|
|
720
|
+
request_title = title or _extract_doc_title(request_path)
|
|
721
|
+
ref = _next_backlog_ref(repo_root, request_title)
|
|
722
|
+
from_version = next((line.split(":", 1)[1].strip() for line in request_lines if line.strip().startswith("> From version:")), _resolved_from_version(repo_root, None))
|
|
723
|
+
product_refs = product_refs or []
|
|
724
|
+
architecture_refs = architecture_refs or []
|
|
725
|
+
product_line = ", ".join(f"`{item}`" for item in product_refs) if product_refs else "(none yet)"
|
|
726
|
+
architecture_line = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
|
|
727
|
+
needs = _bullet_values(_section_lines(request_lines, "Needs"))
|
|
728
|
+
acceptance = _bullet_values(_section_lines(request_lines, "Acceptance criteria"))
|
|
729
|
+
if not needs:
|
|
730
|
+
needs = [f"Deliver a bounded slice for {request_title.lower()}."]
|
|
731
|
+
if not acceptance:
|
|
732
|
+
acceptance = [
|
|
733
|
+
"AC1: The backlog slice stays bounded and reviewable.",
|
|
734
|
+
"AC2: The backlog slice preserves the request's core acceptance criteria.",
|
|
735
|
+
]
|
|
736
|
+
content = "\n".join(
|
|
737
|
+
[
|
|
738
|
+
f"## {ref} - {request_title}",
|
|
739
|
+
f"> From version: {from_version}",
|
|
740
|
+
"> Schema version: 1.0",
|
|
741
|
+
"> Status: Ready",
|
|
742
|
+
"> Understanding: 90%",
|
|
743
|
+
"> Confidence: 85%",
|
|
744
|
+
"> Progress: 0%",
|
|
745
|
+
"> Complexity: High",
|
|
746
|
+
"> Theme: Operator workflow and runtime integration",
|
|
747
|
+
"> Reminder: Update status/understanding/confidence/progress and linked request/task references when you edit this doc.",
|
|
748
|
+
"",
|
|
749
|
+
"# Problem",
|
|
750
|
+
*needs,
|
|
751
|
+
"",
|
|
752
|
+
"# Scope",
|
|
753
|
+
"- In:",
|
|
754
|
+
" - one coherent delivery slice from the source request",
|
|
755
|
+
"- Out:",
|
|
756
|
+
" - unrelated sibling slices that should stay in separate backlog items instead of widening this doc",
|
|
757
|
+
"",
|
|
758
|
+
"# Acceptance criteria",
|
|
759
|
+
*[f"- {item}" for item in acceptance],
|
|
760
|
+
"",
|
|
761
|
+
"# AC Traceability",
|
|
762
|
+
*[f"- request-AC{idx + 1} -> This backlog slice. Proof: {item}" for idx, item in enumerate(acceptance)],
|
|
763
|
+
"",
|
|
764
|
+
"# Decision framing",
|
|
765
|
+
"- Product framing: Not needed",
|
|
766
|
+
"- Product signals: (none detected)",
|
|
767
|
+
"- Product follow-up: No product brief follow-up is expected based on current signals.",
|
|
768
|
+
"- Architecture framing: Not needed",
|
|
769
|
+
"- Architecture signals: (none detected)",
|
|
770
|
+
"- Architecture follow-up: No architecture decision follow-up is expected based on current signals.",
|
|
771
|
+
"",
|
|
772
|
+
"# Links",
|
|
773
|
+
f"- Product brief(s): {product_line}",
|
|
774
|
+
f"- Architecture decision(s): {architecture_line}",
|
|
775
|
+
f"- Request: `{request_path.relative_to(repo_root).as_posix()}`",
|
|
776
|
+
"- Primary task(s): (none yet)",
|
|
777
|
+
"",
|
|
778
|
+
"# AI Context",
|
|
779
|
+
f"- Summary: {request_title}",
|
|
780
|
+
f"- Keywords: backlog-groom, request, {request_title.lower()}, bounded slice",
|
|
781
|
+
f"- Use when: Use when implementing or reviewing the delivery slice for {request_title}.",
|
|
782
|
+
"- Skip when: Skip when the change is unrelated to this delivery slice or its linked request.",
|
|
783
|
+
"",
|
|
784
|
+
"# Priority",
|
|
785
|
+
"- Impact:",
|
|
786
|
+
"- Urgency:",
|
|
787
|
+
"",
|
|
788
|
+
"# Notes",
|
|
789
|
+
f"- Hybrid rationale: Derived from request `{request_path.stem}` and kept bounded to one coherent delivery slice.",
|
|
790
|
+
f"- Source file: `{request_path.relative_to(repo_root).as_posix()}`.",
|
|
791
|
+
"- Generated locally by logics-manager.",
|
|
792
|
+
"",
|
|
793
|
+
]
|
|
794
|
+
).rstrip() + "\n"
|
|
795
|
+
return ref, content
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _build_native_task_from_backlog(
|
|
799
|
+
repo_root: Path,
|
|
800
|
+
backlog_path: Path,
|
|
801
|
+
title: str | None = None,
|
|
802
|
+
*,
|
|
803
|
+
request_refs: list[str] | None = None,
|
|
804
|
+
product_refs: list[str] | None = None,
|
|
805
|
+
architecture_refs: list[str] | None = None,
|
|
806
|
+
) -> tuple[str, str]:
|
|
807
|
+
backlog_lines = backlog_path.read_text(encoding="utf-8").splitlines()
|
|
808
|
+
backlog_title = title or _extract_doc_title(backlog_path)
|
|
809
|
+
ref = _next_task_ref(repo_root, backlog_title)
|
|
810
|
+
from_version = next((line.split(":", 1)[1].strip() for line in backlog_lines if line.strip().startswith("> From version:")), _resolved_from_version(repo_root, None))
|
|
811
|
+
backlog_ref = backlog_path.stem
|
|
812
|
+
request_refs = request_refs or []
|
|
813
|
+
product_refs = product_refs or []
|
|
814
|
+
architecture_refs = architecture_refs or []
|
|
815
|
+
request_line = ", ".join(f"`{item}`" for item in request_refs) if request_refs else "(none yet)"
|
|
816
|
+
product_line = ", ".join(f"`{item}`" for item in product_refs) if product_refs else "(none yet)"
|
|
817
|
+
architecture_line = ", ".join(f"`{item}`" for item in architecture_refs) if architecture_refs else "(none yet)"
|
|
818
|
+
acceptance = _bullet_values(_section_lines(backlog_lines, "Acceptance criteria"))
|
|
819
|
+
if not acceptance:
|
|
820
|
+
acceptance = [
|
|
821
|
+
"AC1: The task remains bounded and executable.",
|
|
822
|
+
"AC2: The task preserves the backlog item's delivery intent.",
|
|
823
|
+
]
|
|
824
|
+
content = "\n".join(
|
|
825
|
+
[
|
|
826
|
+
f"## {ref} - {backlog_title}",
|
|
827
|
+
f"> From version: {from_version}",
|
|
828
|
+
"> Schema version: 1.0",
|
|
829
|
+
"> Status: Ready",
|
|
830
|
+
"> Understanding: 90%",
|
|
831
|
+
"> Confidence: 85%",
|
|
832
|
+
"> Progress: 0%",
|
|
833
|
+
"> Complexity: Medium",
|
|
834
|
+
"> Theme: Implementation delivery",
|
|
835
|
+
"> Reminder: Update status/understanding/confidence/progress and linked request/backlog references when you edit this doc.",
|
|
836
|
+
"",
|
|
837
|
+
"# Definition of Done (DoD)",
|
|
838
|
+
"- [ ] The backlog scope is implemented.",
|
|
839
|
+
"- [ ] Acceptance criteria are covered.",
|
|
840
|
+
"- [ ] Validation passes.",
|
|
841
|
+
"",
|
|
842
|
+
"# Backlog",
|
|
843
|
+
f"- `{backlog_ref}`",
|
|
844
|
+
"",
|
|
845
|
+
"# Acceptance criteria",
|
|
846
|
+
*[f"- {item}" for item in acceptance],
|
|
847
|
+
"",
|
|
848
|
+
"# Validation",
|
|
849
|
+
"- Run `python3 -m logics_manager lint --require-status`.",
|
|
850
|
+
f"- Run `python3 -m logics_manager flow finish task {ref}.md` after implementation.",
|
|
851
|
+
"",
|
|
852
|
+
"# Report",
|
|
853
|
+
"- Implementation complete.",
|
|
854
|
+
"",
|
|
855
|
+
"# AI Context",
|
|
856
|
+
f"- Summary: Implement {backlog_title.lower()}.",
|
|
857
|
+
"- Keywords: task, implementation, backlog, runtime, python",
|
|
858
|
+
"- Use when: You need a bounded implementation task for a backlog item.",
|
|
859
|
+
"- Skip when: The work is still at the request or backlog shaping stage.",
|
|
860
|
+
"",
|
|
861
|
+
"# Links",
|
|
862
|
+
f"- Request: {request_line}",
|
|
863
|
+
f"- Product brief(s): {product_line}",
|
|
864
|
+
f"- Architecture decision(s): {architecture_line}",
|
|
865
|
+
"",
|
|
866
|
+
]
|
|
867
|
+
).rstrip() + "\n"
|
|
868
|
+
return ref, content
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
872
|
+
parser = argparse.ArgumentParser(
|
|
873
|
+
prog="logics-manager flow",
|
|
874
|
+
description="Create Logics docs with consistent IDs, templates, and workflow transitions.",
|
|
875
|
+
)
|
|
876
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
877
|
+
|
|
878
|
+
new_parser = sub.add_parser("new", help="Create a new Logics doc from a template.")
|
|
879
|
+
new_sub = new_parser.add_subparsers(dest="kind", required=True)
|
|
880
|
+
for kind in DOC_KINDS:
|
|
881
|
+
kind_parser = new_sub.add_parser(kind, help=f"Create a new {kind} doc.")
|
|
882
|
+
kind_parser.add_argument("--title", required=True)
|
|
883
|
+
kind_parser.add_argument("--slug", help="Override slug derived from the title.")
|
|
884
|
+
if kind == "request":
|
|
885
|
+
kind_parser.add_argument("--fixture", action="store_true", help="Generate a compact fixture-friendly request.")
|
|
886
|
+
kind_parser.add_argument("--smoke-test", action="store_true", dest="fixture", help="Alias for --fixture.")
|
|
887
|
+
_add_common_doc_args(kind_parser, kind)
|
|
888
|
+
kind_parser.set_defaults(func=cmd_new)
|
|
889
|
+
|
|
890
|
+
companion_parser = sub.add_parser("companion", help="Create a companion doc from the integrated runtime.")
|
|
891
|
+
companion_sub = companion_parser.add_subparsers(dest="kind", required=True)
|
|
892
|
+
for kind in ("product", "architecture"):
|
|
893
|
+
kind_parser = companion_sub.add_parser(kind, help=f"Create a {kind} companion doc.")
|
|
894
|
+
kind_parser.add_argument("--title", required=True)
|
|
895
|
+
kind_parser.add_argument("--source-ref")
|
|
896
|
+
kind_parser.add_argument("--request-ref")
|
|
897
|
+
kind_parser.add_argument("--backlog-ref")
|
|
898
|
+
kind_parser.add_argument("--task-ref")
|
|
899
|
+
kind_parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
900
|
+
kind_parser.add_argument("--dry-run", action="store_true")
|
|
901
|
+
kind_parser.set_defaults(func=cmd_companion)
|
|
902
|
+
|
|
903
|
+
promote_parser = sub.add_parser("promote", help="Promote between Logics stages.")
|
|
904
|
+
promote_sub = promote_parser.add_subparsers(dest="promotion", required=True)
|
|
905
|
+
|
|
906
|
+
r2b = promote_sub.add_parser("request-to-backlog", help="Create a backlog slice from a request.")
|
|
907
|
+
r2b.add_argument("source")
|
|
908
|
+
_add_common_doc_args(r2b, "backlog")
|
|
909
|
+
r2b.set_defaults(func=cmd_promote_request_to_backlog)
|
|
910
|
+
|
|
911
|
+
b2t = promote_sub.add_parser("backlog-to-task", help="Create a task from a backlog item.")
|
|
912
|
+
b2t.add_argument("source")
|
|
913
|
+
_add_common_doc_args(b2t, "task")
|
|
914
|
+
b2t.set_defaults(func=cmd_promote_backlog_to_task)
|
|
915
|
+
|
|
916
|
+
split_parser = sub.add_parser("split", help="Split a request or backlog into bounded children.")
|
|
917
|
+
split_sub = split_parser.add_subparsers(dest="split_kind", required=True)
|
|
918
|
+
|
|
919
|
+
split_request = split_sub.add_parser("request", help="Split a request into multiple backlog items.")
|
|
920
|
+
split_request.add_argument("source")
|
|
921
|
+
split_request.add_argument("--title", action="append", nargs="+", required=True)
|
|
922
|
+
_add_common_doc_args(split_request, "backlog")
|
|
923
|
+
split_request.set_defaults(func=cmd_split_request)
|
|
924
|
+
|
|
925
|
+
split_backlog = split_sub.add_parser("backlog", help="Split a backlog item into multiple tasks.")
|
|
926
|
+
split_backlog.add_argument("source")
|
|
927
|
+
split_backlog.add_argument("--title", action="append", nargs="+", required=True)
|
|
928
|
+
_add_common_doc_args(split_backlog, "task")
|
|
929
|
+
split_backlog.set_defaults(func=cmd_split_backlog)
|
|
930
|
+
|
|
931
|
+
close_parser = sub.add_parser("close", help="Close a request, backlog item, or task and propagate transitions.")
|
|
932
|
+
close_sub = close_parser.add_subparsers(dest="kind", required=True)
|
|
933
|
+
for kind in ("request", "backlog", "task"):
|
|
934
|
+
kind_parser = close_sub.add_parser(kind, help=f"Close a {kind} doc.")
|
|
935
|
+
kind_parser.add_argument("source")
|
|
936
|
+
kind_parser.add_argument("--format", choices=("text", "json"), default="text")
|
|
937
|
+
kind_parser.add_argument("--dry-run", action="store_true")
|
|
938
|
+
kind_parser.set_defaults(func=cmd_close)
|
|
939
|
+
|
|
940
|
+
finish_parser = sub.add_parser("finish", help="Finish a task and verify the closure chain.")
|
|
941
|
+
finish_sub = finish_parser.add_subparsers(dest="kind", required=True)
|
|
942
|
+
finish_task = finish_sub.add_parser("task", help="Finish a task.")
|
|
943
|
+
finish_task.add_argument("source")
|
|
944
|
+
finish_task.add_argument("--format", choices=("text", "json"), default="text")
|
|
945
|
+
finish_task.add_argument("--dry-run", action="store_true")
|
|
946
|
+
finish_task.set_defaults(func=cmd_finish_task)
|
|
947
|
+
|
|
948
|
+
return parser
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def cmd_new(args: argparse.Namespace) -> dict[str, object]:
|
|
952
|
+
doc_kind = DOC_KINDS[args.kind]
|
|
953
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
954
|
+
planned = _plan_doc(repo_root, doc_kind.directory, doc_kind.prefix, args.slug or args.title, dry_run=args.dry_run)
|
|
955
|
+
payload: dict[str, object] = {
|
|
956
|
+
"command": "new",
|
|
957
|
+
"kind": doc_kind.kind,
|
|
958
|
+
"ref": planned.ref,
|
|
959
|
+
"path": planned.path.relative_to(repo_root).as_posix(),
|
|
960
|
+
"dry_run": args.dry_run,
|
|
961
|
+
}
|
|
962
|
+
if doc_kind.kind == "request":
|
|
963
|
+
content = _build_native_request_doc(repo_root, planned.ref, args.title, args)
|
|
964
|
+
if not args.dry_run:
|
|
965
|
+
planned.path.parent.mkdir(parents=True, exist_ok=True)
|
|
966
|
+
planned.path.write_text(content, encoding="utf-8")
|
|
967
|
+
print(f"Wrote {planned.path}")
|
|
968
|
+
else:
|
|
969
|
+
preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
|
|
970
|
+
print(f"[dry-run] would write: {planned.path}")
|
|
971
|
+
print(preview)
|
|
972
|
+
print(f"Created {doc_kind.kind}: {payload['path']}")
|
|
973
|
+
return payload
|
|
974
|
+
if doc_kind.kind == "backlog":
|
|
975
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
976
|
+
repo_root,
|
|
977
|
+
args.title,
|
|
978
|
+
request_ref=None,
|
|
979
|
+
backlog_ref=planned.ref,
|
|
980
|
+
task_ref=None,
|
|
981
|
+
args=args,
|
|
982
|
+
)
|
|
983
|
+
content = _build_native_backlog_doc(
|
|
984
|
+
repo_root,
|
|
985
|
+
planned.ref,
|
|
986
|
+
args.title,
|
|
987
|
+
args,
|
|
988
|
+
request_ref=None,
|
|
989
|
+
product_refs=product_refs,
|
|
990
|
+
architecture_refs=architecture_refs,
|
|
991
|
+
)
|
|
992
|
+
elif doc_kind.kind == "task":
|
|
993
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
994
|
+
repo_root,
|
|
995
|
+
args.title,
|
|
996
|
+
request_ref=None,
|
|
997
|
+
backlog_ref=None,
|
|
998
|
+
task_ref=planned.ref,
|
|
999
|
+
args=args,
|
|
1000
|
+
)
|
|
1001
|
+
content = _build_native_task_doc(
|
|
1002
|
+
repo_root,
|
|
1003
|
+
planned.ref,
|
|
1004
|
+
args.title,
|
|
1005
|
+
args,
|
|
1006
|
+
backlog_ref=None,
|
|
1007
|
+
request_refs=[],
|
|
1008
|
+
product_refs=product_refs,
|
|
1009
|
+
architecture_refs=architecture_refs,
|
|
1010
|
+
)
|
|
1011
|
+
else:
|
|
1012
|
+
raise SystemExit(f"Unsupported doc kind `{doc_kind.kind}` for native creation.")
|
|
1013
|
+
|
|
1014
|
+
if not args.dry_run:
|
|
1015
|
+
planned.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1016
|
+
planned.path.write_text(content, encoding="utf-8")
|
|
1017
|
+
print(f"Wrote {planned.path}")
|
|
1018
|
+
else:
|
|
1019
|
+
preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
|
|
1020
|
+
print(f"[dry-run] would write: {planned.path}")
|
|
1021
|
+
print(preview)
|
|
1022
|
+
|
|
1023
|
+
print(f"Created {doc_kind.kind}: {payload['path']}")
|
|
1024
|
+
return payload
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def cmd_companion(args: argparse.Namespace) -> dict[str, object]:
|
|
1028
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1029
|
+
request_ref, backlog_ref, task_ref = _resolve_workflow_refs_for_companion(
|
|
1030
|
+
getattr(args, "source_ref", None),
|
|
1031
|
+
request_ref=getattr(args, "request_ref", None),
|
|
1032
|
+
backlog_ref=getattr(args, "backlog_ref", None),
|
|
1033
|
+
task_ref=getattr(args, "task_ref", None),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
if args.kind == "product":
|
|
1037
|
+
ref, content = _build_native_product_brief(
|
|
1038
|
+
repo_root,
|
|
1039
|
+
args.title,
|
|
1040
|
+
request_ref=request_ref,
|
|
1041
|
+
backlog_ref=backlog_ref,
|
|
1042
|
+
task_ref=task_ref,
|
|
1043
|
+
)
|
|
1044
|
+
planned_path = repo_root / "logics" / "product" / f"{ref}.md"
|
|
1045
|
+
elif args.kind == "architecture":
|
|
1046
|
+
ref, content = _build_native_adr(
|
|
1047
|
+
repo_root,
|
|
1048
|
+
args.title,
|
|
1049
|
+
request_ref=request_ref,
|
|
1050
|
+
backlog_ref=backlog_ref,
|
|
1051
|
+
task_ref=task_ref,
|
|
1052
|
+
)
|
|
1053
|
+
planned_path = repo_root / "logics" / "architecture" / f"{ref}.md"
|
|
1054
|
+
else:
|
|
1055
|
+
raise SystemExit(f"Unsupported companion kind `{args.kind}`.")
|
|
1056
|
+
|
|
1057
|
+
if not args.dry_run:
|
|
1058
|
+
planned_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1059
|
+
planned_path.write_text(content, encoding="utf-8")
|
|
1060
|
+
print(f"Wrote {planned_path}")
|
|
1061
|
+
else:
|
|
1062
|
+
preview = content if len(content) <= 2000 else content[:2000] + "\n...\n"
|
|
1063
|
+
print(f"[dry-run] would write: {planned_path}")
|
|
1064
|
+
print(preview)
|
|
1065
|
+
|
|
1066
|
+
payload = {
|
|
1067
|
+
"command": "companion",
|
|
1068
|
+
"kind": args.kind,
|
|
1069
|
+
"ref": ref,
|
|
1070
|
+
"path": planned_path.relative_to(repo_root).as_posix(),
|
|
1071
|
+
"request_ref": request_ref,
|
|
1072
|
+
"backlog_ref": backlog_ref,
|
|
1073
|
+
"task_ref": task_ref,
|
|
1074
|
+
"dry_run": args.dry_run,
|
|
1075
|
+
}
|
|
1076
|
+
if args.format == "json":
|
|
1077
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1078
|
+
else:
|
|
1079
|
+
print(f"Created companion doc: {payload['path']}")
|
|
1080
|
+
return payload
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def cmd_promote_request_to_backlog(args: argparse.Namespace) -> dict[str, object]:
|
|
1084
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1085
|
+
source_path = Path(args.source).resolve()
|
|
1086
|
+
if not source_path.is_file():
|
|
1087
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1088
|
+
title = _extract_doc_title(source_path)
|
|
1089
|
+
ref, _ = _build_native_backlog_from_request(repo_root, source_path, title)
|
|
1090
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
1091
|
+
repo_root,
|
|
1092
|
+
title,
|
|
1093
|
+
request_ref=source_path.stem,
|
|
1094
|
+
backlog_ref=ref,
|
|
1095
|
+
task_ref=None,
|
|
1096
|
+
args=args,
|
|
1097
|
+
)
|
|
1098
|
+
_, content = _build_native_backlog_from_request(
|
|
1099
|
+
repo_root,
|
|
1100
|
+
source_path,
|
|
1101
|
+
title,
|
|
1102
|
+
product_refs=product_refs,
|
|
1103
|
+
architecture_refs=architecture_refs,
|
|
1104
|
+
)
|
|
1105
|
+
planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
|
|
1106
|
+
if not args.dry_run:
|
|
1107
|
+
planned_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1108
|
+
planned_path.write_text(content, encoding="utf-8")
|
|
1109
|
+
_append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
|
|
1110
|
+
payload = {
|
|
1111
|
+
"command": "promote",
|
|
1112
|
+
"promotion": "request-to-backlog",
|
|
1113
|
+
"source": source_path.relative_to(repo_root).as_posix(),
|
|
1114
|
+
"created_ref": ref,
|
|
1115
|
+
"created_path": planned_path.relative_to(repo_root).as_posix(),
|
|
1116
|
+
"dry_run": args.dry_run,
|
|
1117
|
+
}
|
|
1118
|
+
if args.format == "json":
|
|
1119
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1120
|
+
else:
|
|
1121
|
+
print(f"Created backlog slice from request: {payload['created_path']}")
|
|
1122
|
+
return payload
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def cmd_promote_backlog_to_task(args: argparse.Namespace) -> dict[str, object]:
|
|
1126
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1127
|
+
source_path = Path(args.source).resolve()
|
|
1128
|
+
if not source_path.is_file():
|
|
1129
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1130
|
+
title = _extract_doc_title(source_path)
|
|
1131
|
+
source_text = source_path.read_text(encoding="utf-8")
|
|
1132
|
+
request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
|
|
1133
|
+
ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
|
|
1134
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
1135
|
+
repo_root,
|
|
1136
|
+
title,
|
|
1137
|
+
request_ref=request_refs[0] if request_refs else None,
|
|
1138
|
+
backlog_ref=source_path.stem,
|
|
1139
|
+
task_ref=ref,
|
|
1140
|
+
args=args,
|
|
1141
|
+
)
|
|
1142
|
+
_, content = _build_native_task_from_backlog(
|
|
1143
|
+
repo_root,
|
|
1144
|
+
source_path,
|
|
1145
|
+
title,
|
|
1146
|
+
request_refs=request_refs,
|
|
1147
|
+
product_refs=product_refs,
|
|
1148
|
+
architecture_refs=architecture_refs,
|
|
1149
|
+
)
|
|
1150
|
+
planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
|
|
1151
|
+
if not args.dry_run:
|
|
1152
|
+
planned_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1153
|
+
planned_path.write_text(content, encoding="utf-8")
|
|
1154
|
+
_append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
|
|
1155
|
+
payload = {
|
|
1156
|
+
"command": "promote",
|
|
1157
|
+
"promotion": "backlog-to-task",
|
|
1158
|
+
"source": source_path.relative_to(repo_root).as_posix(),
|
|
1159
|
+
"created_ref": ref,
|
|
1160
|
+
"created_path": planned_path.relative_to(repo_root).as_posix(),
|
|
1161
|
+
"dry_run": args.dry_run,
|
|
1162
|
+
}
|
|
1163
|
+
if args.format == "json":
|
|
1164
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1165
|
+
else:
|
|
1166
|
+
print(f"Created task from backlog: {payload['created_path']}")
|
|
1167
|
+
return payload
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def cmd_split_request(args: argparse.Namespace) -> dict[str, object]:
|
|
1171
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1172
|
+
source_path = Path(args.source).resolve()
|
|
1173
|
+
if not source_path.is_file():
|
|
1174
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1175
|
+
titles = _split_titles([title for group in args.title for title in group])
|
|
1176
|
+
created_refs: list[str] = []
|
|
1177
|
+
for title in titles:
|
|
1178
|
+
ref, _ = _build_native_backlog_from_request(
|
|
1179
|
+
repo_root,
|
|
1180
|
+
source_path,
|
|
1181
|
+
title,
|
|
1182
|
+
)
|
|
1183
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
1184
|
+
repo_root,
|
|
1185
|
+
title,
|
|
1186
|
+
request_ref=source_path.stem,
|
|
1187
|
+
backlog_ref=ref,
|
|
1188
|
+
task_ref=None,
|
|
1189
|
+
args=args,
|
|
1190
|
+
)
|
|
1191
|
+
_, content = _build_native_backlog_from_request(
|
|
1192
|
+
repo_root,
|
|
1193
|
+
source_path,
|
|
1194
|
+
title,
|
|
1195
|
+
product_refs=product_refs,
|
|
1196
|
+
architecture_refs=architecture_refs,
|
|
1197
|
+
)
|
|
1198
|
+
planned_path = repo_root / "logics" / "backlog" / f"{ref}.md"
|
|
1199
|
+
if not args.dry_run:
|
|
1200
|
+
planned_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1201
|
+
planned_path.write_text(content, encoding="utf-8")
|
|
1202
|
+
_append_doc_section_bullets(source_path, "Backlog", [f"`{ref}`"], dry_run=False)
|
|
1203
|
+
created_refs.append(ref)
|
|
1204
|
+
payload = {
|
|
1205
|
+
"command": "split",
|
|
1206
|
+
"kind": "request",
|
|
1207
|
+
"source": source_path.relative_to(repo_root).as_posix(),
|
|
1208
|
+
"created_refs": created_refs,
|
|
1209
|
+
"dry_run": args.dry_run,
|
|
1210
|
+
}
|
|
1211
|
+
if args.format == "json":
|
|
1212
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1213
|
+
else:
|
|
1214
|
+
print(f"Split request into {len(created_refs)} backlog item(s): {', '.join(created_refs)}")
|
|
1215
|
+
return payload
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def cmd_split_backlog(args: argparse.Namespace) -> dict[str, object]:
|
|
1219
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1220
|
+
source_path = Path(args.source).resolve()
|
|
1221
|
+
if not source_path.is_file():
|
|
1222
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1223
|
+
source_text = source_path.read_text(encoding="utf-8")
|
|
1224
|
+
request_refs = sorted(_extract_refs(_strip_mermaid_blocks(source_text), DOC_KINDS["request"].prefix))
|
|
1225
|
+
titles = _split_titles([title for group in args.title for title in group])
|
|
1226
|
+
created_refs: list[str] = []
|
|
1227
|
+
for title in titles:
|
|
1228
|
+
ref, _ = _build_native_task_from_backlog(repo_root, source_path, title)
|
|
1229
|
+
product_refs, architecture_refs = _create_native_companion_docs(
|
|
1230
|
+
repo_root,
|
|
1231
|
+
title,
|
|
1232
|
+
request_ref=request_refs[0] if request_refs else None,
|
|
1233
|
+
backlog_ref=source_path.stem,
|
|
1234
|
+
task_ref=ref,
|
|
1235
|
+
args=args,
|
|
1236
|
+
)
|
|
1237
|
+
_, content = _build_native_task_from_backlog(
|
|
1238
|
+
repo_root,
|
|
1239
|
+
source_path,
|
|
1240
|
+
title,
|
|
1241
|
+
request_refs=request_refs,
|
|
1242
|
+
product_refs=product_refs,
|
|
1243
|
+
architecture_refs=architecture_refs,
|
|
1244
|
+
)
|
|
1245
|
+
planned_path = repo_root / "logics" / "tasks" / f"{ref}.md"
|
|
1246
|
+
if not args.dry_run:
|
|
1247
|
+
planned_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1248
|
+
planned_path.write_text(content, encoding="utf-8")
|
|
1249
|
+
_append_doc_section_bullets(source_path, "Tasks", [f"`{ref}`"], dry_run=False)
|
|
1250
|
+
created_refs.append(ref)
|
|
1251
|
+
payload = {
|
|
1252
|
+
"command": "split",
|
|
1253
|
+
"kind": "backlog",
|
|
1254
|
+
"source": source_path.relative_to(repo_root).as_posix(),
|
|
1255
|
+
"created_refs": created_refs,
|
|
1256
|
+
"dry_run": args.dry_run,
|
|
1257
|
+
}
|
|
1258
|
+
if args.format == "json":
|
|
1259
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1260
|
+
else:
|
|
1261
|
+
print(f"Split backlog item into {len(created_refs)} task(s): {', '.join(created_refs)}")
|
|
1262
|
+
return payload
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def cmd_close(args: argparse.Namespace) -> dict[str, object]:
|
|
1266
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1267
|
+
kind = DOC_KINDS[args.kind]
|
|
1268
|
+
source_path = Path(args.source).resolve()
|
|
1269
|
+
if not source_path.is_file():
|
|
1270
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1271
|
+
if not source_path.stem.startswith(f"{kind.prefix}_"):
|
|
1272
|
+
raise SystemExit(f"Expected a `{kind.prefix}_...` file for kind `{kind.kind}`. Got: {source_path.name}")
|
|
1273
|
+
|
|
1274
|
+
_close_chain_for_kind(repo_root, source_path, kind, dry_run=args.dry_run)
|
|
1275
|
+
print(f"Closed {kind.kind}: {source_path.relative_to(repo_root)}")
|
|
1276
|
+
|
|
1277
|
+
payload = {
|
|
1278
|
+
"command": "close",
|
|
1279
|
+
"kind": kind.kind,
|
|
1280
|
+
"source": source_path.relative_to(repo_root).as_posix(),
|
|
1281
|
+
"dry_run": args.dry_run,
|
|
1282
|
+
}
|
|
1283
|
+
if args.format == "json":
|
|
1284
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1285
|
+
return payload
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _verify_finished_task_chain(repo_root: Path, task_path: Path) -> list[str]:
|
|
1289
|
+
issues: list[str] = []
|
|
1290
|
+
task_ref = task_path.stem
|
|
1291
|
+
task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
|
|
1292
|
+
item_refs = sorted(_extract_refs(task_text, "item"))
|
|
1293
|
+
|
|
1294
|
+
if not item_refs:
|
|
1295
|
+
return [f"task `{task_ref}` has no linked backlog item reference"]
|
|
1296
|
+
|
|
1297
|
+
processed_request_refs: set[str] = set()
|
|
1298
|
+
for item_ref in item_refs:
|
|
1299
|
+
item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
|
|
1300
|
+
if item_path is None:
|
|
1301
|
+
issues.append(f"task `{task_ref}` references missing backlog item `{item_ref}`")
|
|
1302
|
+
continue
|
|
1303
|
+
if not _is_doc_done(item_path, DOC_KINDS["backlog"]):
|
|
1304
|
+
issues.append(f"linked backlog item `{item_ref}` is not closed after finishing task `{task_ref}`")
|
|
1305
|
+
|
|
1306
|
+
item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
|
|
1307
|
+
request_refs = sorted(_extract_refs(item_text, "req"))
|
|
1308
|
+
if not request_refs:
|
|
1309
|
+
issues.append(f"linked backlog item `{item_ref}` has no request reference")
|
|
1310
|
+
continue
|
|
1311
|
+
|
|
1312
|
+
for request_ref in request_refs:
|
|
1313
|
+
if request_ref in processed_request_refs:
|
|
1314
|
+
continue
|
|
1315
|
+
processed_request_refs.add(request_ref)
|
|
1316
|
+
request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
|
|
1317
|
+
if request_path is None:
|
|
1318
|
+
issues.append(f"backlog item `{item_ref}` references missing request `{request_ref}`")
|
|
1319
|
+
continue
|
|
1320
|
+
|
|
1321
|
+
linked_items = _collect_docs_linking_ref(repo_root, DOC_KINDS["backlog"], request_ref)
|
|
1322
|
+
if linked_items and all(_is_doc_done(linked_item, DOC_KINDS["backlog"]) for linked_item in linked_items):
|
|
1323
|
+
if not _is_doc_done(request_path, DOC_KINDS["request"]):
|
|
1324
|
+
issues.append(f"request `{request_ref}` should be closed because all linked backlog items are done")
|
|
1325
|
+
|
|
1326
|
+
return issues
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _record_finished_task_follow_up(repo_root: Path, task_path: Path, dry_run: bool) -> None:
|
|
1330
|
+
task_ref = task_path.stem
|
|
1331
|
+
task_text = _strip_mermaid_blocks(task_path.read_text(encoding="utf-8"))
|
|
1332
|
+
item_refs = sorted(_extract_refs(task_text, "item"))
|
|
1333
|
+
request_refs: set[str] = set()
|
|
1334
|
+
|
|
1335
|
+
for item_ref in item_refs:
|
|
1336
|
+
item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
|
|
1337
|
+
if item_path is None:
|
|
1338
|
+
continue
|
|
1339
|
+
item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
|
|
1340
|
+
request_refs.update(_extract_refs(item_text, "req"))
|
|
1341
|
+
_append_section_bullets(
|
|
1342
|
+
item_path,
|
|
1343
|
+
"Notes",
|
|
1344
|
+
[f"- Task `{task_ref}` was finished via `logics-manager flow finish task` on {date.today().isoformat()}."],
|
|
1345
|
+
dry_run,
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
validation_bullets = [
|
|
1349
|
+
f"- Finish workflow executed on {date.today().isoformat()}.",
|
|
1350
|
+
"- Linked backlog/request close verification passed.",
|
|
1351
|
+
]
|
|
1352
|
+
report_bullets = [
|
|
1353
|
+
f"- Finished on {date.today().isoformat()}.",
|
|
1354
|
+
f"- Linked backlog item(s): {', '.join(f'`{ref}`' for ref in item_refs) if item_refs else '(none)'}",
|
|
1355
|
+
f"- Related request(s): {', '.join(f'`{ref}`' for ref in sorted(request_refs)) if request_refs else '(none)'}",
|
|
1356
|
+
]
|
|
1357
|
+
_append_section_bullets(task_path, "Validation", validation_bullets, dry_run)
|
|
1358
|
+
_append_section_bullets(task_path, "Report", report_bullets, dry_run)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _maybe_close_request_chain(repo_root: Path, request_ref: str, dry_run: bool) -> None:
|
|
1362
|
+
request_path = _resolve_doc_path(repo_root, DOC_KINDS["request"], request_ref)
|
|
1363
|
+
if request_path is None:
|
|
1364
|
+
return
|
|
1365
|
+
|
|
1366
|
+
linked_items = _collect_docs_linking_ref(repo_root, DOC_KINDS["backlog"], request_ref)
|
|
1367
|
+
if not linked_items:
|
|
1368
|
+
return
|
|
1369
|
+
|
|
1370
|
+
if all(_is_doc_done(item_path, DOC_KINDS["backlog"]) for item_path in linked_items):
|
|
1371
|
+
if not _is_doc_done(request_path, DOC_KINDS["request"]):
|
|
1372
|
+
_close_doc(request_path, DOC_KINDS["request"], dry_run)
|
|
1373
|
+
print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _close_chain_for_kind(repo_root: Path, source_path: Path, kind: DOC_KINDS, *, dry_run: bool) -> None:
|
|
1377
|
+
_close_doc(source_path, kind, dry_run)
|
|
1378
|
+
|
|
1379
|
+
text = _strip_mermaid_blocks(source_path.read_text(encoding="utf-8"))
|
|
1380
|
+
processed_request_refs: set[str] = set()
|
|
1381
|
+
|
|
1382
|
+
if kind.kind == "task":
|
|
1383
|
+
_mark_section_checkboxes_done(source_path, "Definition of Done (DoD)", dry_run)
|
|
1384
|
+
_record_finished_task_follow_up(repo_root, source_path, dry_run)
|
|
1385
|
+
|
|
1386
|
+
linked_item_refs = sorted(_extract_refs(text, DOC_KINDS["backlog"].prefix))
|
|
1387
|
+
for item_ref in linked_item_refs:
|
|
1388
|
+
item_path = _resolve_doc_path(repo_root, DOC_KINDS["backlog"], item_ref)
|
|
1389
|
+
if item_path is None:
|
|
1390
|
+
continue
|
|
1391
|
+
linked_tasks = _collect_docs_linking_ref(repo_root, DOC_KINDS["task"], item_ref)
|
|
1392
|
+
if linked_tasks and all(_is_doc_done(task_path, DOC_KINDS["task"]) for task_path in linked_tasks):
|
|
1393
|
+
if not _is_doc_done(item_path, DOC_KINDS["backlog"]):
|
|
1394
|
+
_close_doc(item_path, DOC_KINDS["backlog"], dry_run)
|
|
1395
|
+
print(f"Auto-closed backlog item {item_ref} (all linked tasks are done).")
|
|
1396
|
+
|
|
1397
|
+
item_text = _strip_mermaid_blocks(item_path.read_text(encoding="utf-8"))
|
|
1398
|
+
for request_ref in sorted(_extract_refs(item_text, DOC_KINDS["request"].prefix)):
|
|
1399
|
+
if request_ref in processed_request_refs:
|
|
1400
|
+
continue
|
|
1401
|
+
processed_request_refs.add(request_ref)
|
|
1402
|
+
_maybe_close_request_chain(repo_root, request_ref, dry_run)
|
|
1403
|
+
|
|
1404
|
+
if kind.kind == "backlog":
|
|
1405
|
+
for request_ref in sorted(_extract_refs(text, DOC_KINDS["request"].prefix)):
|
|
1406
|
+
if request_ref in processed_request_refs:
|
|
1407
|
+
continue
|
|
1408
|
+
processed_request_refs.add(request_ref)
|
|
1409
|
+
_maybe_close_request_chain(repo_root, request_ref, dry_run)
|
|
1410
|
+
|
|
1411
|
+
if kind.kind == "request":
|
|
1412
|
+
_maybe_close_request_chain(repo_root, source_path.stem, dry_run)
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def cmd_finish_task(args: argparse.Namespace) -> dict[str, object]:
|
|
1416
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
1417
|
+
source_path = Path(args.source).resolve()
|
|
1418
|
+
if not source_path.is_file():
|
|
1419
|
+
raise SystemExit(f"Source not found: {source_path}")
|
|
1420
|
+
if not source_path.stem.startswith(f"{DOC_KINDS['task'].prefix}_"):
|
|
1421
|
+
raise SystemExit(f"Expected a `{DOC_KINDS['task'].prefix}_...` task file. Got: {source_path.name}")
|
|
1422
|
+
|
|
1423
|
+
_close_chain_for_kind(repo_root, source_path, DOC_KINDS["task"], dry_run=args.dry_run)
|
|
1424
|
+
|
|
1425
|
+
if args.dry_run:
|
|
1426
|
+
payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": True}
|
|
1427
|
+
print("Dry run: skipped post-close verification.")
|
|
1428
|
+
return payload
|
|
1429
|
+
|
|
1430
|
+
issues = _verify_finished_task_chain(repo_root, source_path)
|
|
1431
|
+
if issues:
|
|
1432
|
+
details = "\n".join(f"- {issue}" for issue in issues)
|
|
1433
|
+
raise SystemExit(f"Finish verification failed:\n{details}")
|
|
1434
|
+
|
|
1435
|
+
payload = {"command": "finish", "kind": "task", "source": source_path.relative_to(repo_root).as_posix(), "dry_run": False}
|
|
1436
|
+
if args.format == "json":
|
|
1437
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1438
|
+
else:
|
|
1439
|
+
print(f"Finish verification: OK for {source_path.relative_to(repo_root)}")
|
|
1440
|
+
return payload
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def main(argv: list[str]) -> int:
|
|
1444
|
+
parser = build_parser()
|
|
1445
|
+
args = parser.parse_args(argv)
|
|
1446
|
+
if args.command not in {"new", "companion", "promote", "split", "close", "finish"}:
|
|
1447
|
+
raise SystemExit("Unsupported flow subcommand for the native CLI slice.")
|
|
1448
|
+
payload = args.func(args)
|
|
1449
|
+
return 0 if isinstance(payload, dict) else 1
|