@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,604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import find_repo_root
|
|
12
|
+
from .lint import expected_workflow_mermaid_signature
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class WorkflowDocModel:
|
|
17
|
+
kind: str
|
|
18
|
+
path: str
|
|
19
|
+
ref: str
|
|
20
|
+
title: str
|
|
21
|
+
indicators: dict[str, str]
|
|
22
|
+
sections: dict[str, list[str]]
|
|
23
|
+
refs: dict[str, list[str]]
|
|
24
|
+
ai_context: dict[str, str]
|
|
25
|
+
schema_version: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DOC_KINDS = {
|
|
29
|
+
"request": {"directory": "logics/request", "prefix": "req"},
|
|
30
|
+
"backlog": {"directory": "logics/backlog", "prefix": "item"},
|
|
31
|
+
"task": {"directory": "logics/tasks", "prefix": "task"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_find_repo_root = find_repo_root
|
|
35
|
+
|
|
36
|
+
REF_PREFIXES = ("req", "item", "task", "prod", "adr", "spec")
|
|
37
|
+
_CONTEXT_PACK_CACHE: dict[str, dict[str, object]] = {}
|
|
38
|
+
MERMAID_BLOCK_PATTERN = re.compile(r"```mermaid\s*\n(.*?)\n```", re.DOTALL)
|
|
39
|
+
MERMAID_SIGNATURE_PATTERN = re.compile(r"^\s*%%\s*logics-signature:\s*(.+?)\s*$", re.MULTILINE)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_text(path: Path) -> str:
|
|
43
|
+
return path.read_text(encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_lines(path: Path) -> list[str]:
|
|
47
|
+
return _read_text(path).splitlines()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _indicator_value(lines: list[str], key: str) -> str | None:
|
|
51
|
+
pattern = re.compile(rf"^\s*>\s*{re.escape(key)}\s*:\s*(.+?)\s*$")
|
|
52
|
+
for line in lines:
|
|
53
|
+
match = pattern.match(line)
|
|
54
|
+
if match:
|
|
55
|
+
return match.group(1).strip()
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _section_lines(lines: list[str], heading: str) -> list[str]:
|
|
60
|
+
target = heading.strip().lower()
|
|
61
|
+
start_idx = None
|
|
62
|
+
for idx, line in enumerate(lines):
|
|
63
|
+
if line.startswith("# ") and line[2:].strip().lower() == target:
|
|
64
|
+
start_idx = idx + 1
|
|
65
|
+
break
|
|
66
|
+
if start_idx is None:
|
|
67
|
+
return []
|
|
68
|
+
out: list[str] = []
|
|
69
|
+
for idx in range(start_idx, len(lines)):
|
|
70
|
+
line = lines[idx]
|
|
71
|
+
if line.startswith("# "):
|
|
72
|
+
break
|
|
73
|
+
out.append(line)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_refs(text: str, prefix: str) -> list[str]:
|
|
78
|
+
pattern = re.compile(rf"\b{re.escape(prefix)}_\d{{3}}_[a-z0-9_]+\b")
|
|
79
|
+
return sorted({match.group(0) for match in pattern.finditer(text)})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _strip_mermaid_blocks(text: str) -> str:
|
|
83
|
+
return MERMAID_BLOCK_PATTERN.sub("", text)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_title(lines: list[str]) -> str:
|
|
87
|
+
for line in lines:
|
|
88
|
+
if line.startswith("## "):
|
|
89
|
+
payload = line.removeprefix("## ").strip()
|
|
90
|
+
if " - " in payload:
|
|
91
|
+
return payload.split(" - ", 1)[1].strip()
|
|
92
|
+
return payload
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_ai_context(sections: dict[str, list[str]]) -> dict[str, str]:
|
|
97
|
+
fields: dict[str, str] = {}
|
|
98
|
+
for line in sections.get("AI Context", []):
|
|
99
|
+
match = re.match(r"^\s*-\s*([^:]+)\s*:\s*(.+?)\s*$", line.strip())
|
|
100
|
+
if match:
|
|
101
|
+
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
|
102
|
+
return fields
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_sections(text: str) -> dict[str, list[str]]:
|
|
106
|
+
sections: dict[str, list[str]] = {}
|
|
107
|
+
current: str | None = None
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
if line.startswith("# "):
|
|
110
|
+
current = line[2:].strip()
|
|
111
|
+
sections.setdefault(current, [])
|
|
112
|
+
continue
|
|
113
|
+
if current is not None:
|
|
114
|
+
sections[current].append(line)
|
|
115
|
+
return sections
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detect_workflow_kind(path: Path) -> str:
|
|
119
|
+
normalized = path.as_posix()
|
|
120
|
+
for kind, spec in DOC_KINDS.items():
|
|
121
|
+
if f"/{spec['directory']}/" in f"/{normalized}":
|
|
122
|
+
return kind
|
|
123
|
+
return "unknown"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_workflow_doc(path: Path, *, repo_root: Path | None = None) -> WorkflowDocModel:
|
|
127
|
+
text = _read_text(path)
|
|
128
|
+
lines = text.splitlines()
|
|
129
|
+
sections = _extract_sections(text)
|
|
130
|
+
indicators = {key: value for key in ("From version", "Schema version", "Status", "Understanding", "Confidence", "Progress", "Complexity", "Theme", "Date", "Drivers", "Related request", "Related backlog", "Related task", "Reminder") if (value := _indicator_value(lines, key)) is not None}
|
|
131
|
+
return WorkflowDocModel(
|
|
132
|
+
kind=_detect_workflow_kind(path),
|
|
133
|
+
path=(path.relative_to(repo_root).as_posix() if repo_root is not None else path.as_posix()),
|
|
134
|
+
ref=path.stem,
|
|
135
|
+
title=_extract_title(lines) or path.stem,
|
|
136
|
+
indicators=indicators,
|
|
137
|
+
sections=sections,
|
|
138
|
+
refs={prefix: _extract_refs(_strip_mermaid_blocks(text), prefix) for prefix in REF_PREFIXES},
|
|
139
|
+
ai_context=_extract_ai_context(sections),
|
|
140
|
+
schema_version=indicators.get("Schema version", "1.0"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _load_workflow_docs(repo_root: Path) -> dict[str, WorkflowDocModel]:
|
|
145
|
+
docs: dict[str, WorkflowDocModel] = {}
|
|
146
|
+
for kind in DOC_KINDS.values():
|
|
147
|
+
directory = repo_root / kind["directory"]
|
|
148
|
+
if not directory.is_dir():
|
|
149
|
+
continue
|
|
150
|
+
for path in sorted(directory.glob(f"{kind['prefix']}_*.md")):
|
|
151
|
+
doc = parse_workflow_doc(path, repo_root=repo_root)
|
|
152
|
+
docs[doc.ref] = doc
|
|
153
|
+
return docs
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _workflow_neighborhood(seed: WorkflowDocModel, docs: dict[str, WorkflowDocModel]) -> list[WorkflowDocModel]:
|
|
157
|
+
ordered: list[WorkflowDocModel] = [seed]
|
|
158
|
+
seen = {seed.ref}
|
|
159
|
+
linked_refs = []
|
|
160
|
+
for values in seed.refs.values():
|
|
161
|
+
linked_refs.extend(values)
|
|
162
|
+
for ref in linked_refs:
|
|
163
|
+
candidate = docs.get(ref)
|
|
164
|
+
if candidate is None or candidate.ref in seen:
|
|
165
|
+
continue
|
|
166
|
+
ordered.append(candidate)
|
|
167
|
+
seen.add(candidate.ref)
|
|
168
|
+
for candidate in docs.values():
|
|
169
|
+
if candidate.ref in seen:
|
|
170
|
+
continue
|
|
171
|
+
if seed.ref in sum(candidate.refs.values(), []):
|
|
172
|
+
ordered.append(candidate)
|
|
173
|
+
seen.add(candidate.ref)
|
|
174
|
+
return ordered
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _context_profile_limit(profile: str) -> int:
|
|
178
|
+
return {"tiny": 2, "normal": 4, "deep": 8}[profile]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _git_changed_paths(repo_root: Path) -> list[str]:
|
|
182
|
+
try:
|
|
183
|
+
result = __import__("subprocess").run(
|
|
184
|
+
["git", "diff", "--name-only", "--relative=."],
|
|
185
|
+
cwd=repo_root,
|
|
186
|
+
stdout=__import__("subprocess").PIPE,
|
|
187
|
+
stderr=__import__("subprocess").PIPE,
|
|
188
|
+
text=True,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
except OSError:
|
|
192
|
+
return []
|
|
193
|
+
if result.returncode != 0:
|
|
194
|
+
return []
|
|
195
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _context_pack_doc_entry(doc: WorkflowDocModel, mode: str) -> dict[str, object]:
|
|
199
|
+
entry = {
|
|
200
|
+
"ref": doc.ref,
|
|
201
|
+
"kind": doc.kind,
|
|
202
|
+
"path": doc.path,
|
|
203
|
+
"title": doc.title,
|
|
204
|
+
"status": doc.indicators.get("Status", ""),
|
|
205
|
+
"schema_version": doc.schema_version,
|
|
206
|
+
"ai_context": doc.ai_context,
|
|
207
|
+
"linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
|
|
208
|
+
}
|
|
209
|
+
if mode == "summary-only":
|
|
210
|
+
return entry
|
|
211
|
+
section_names = {
|
|
212
|
+
"request": ["Needs", "Acceptance criteria"],
|
|
213
|
+
"backlog": ["Problem", "Acceptance criteria"],
|
|
214
|
+
"task": ["Context", "Validation"],
|
|
215
|
+
}.get(doc.kind, [])
|
|
216
|
+
entry["sections"] = {heading: [line for line in doc.sections.get(heading, []) if line.strip()][:6] for heading in section_names}
|
|
217
|
+
return entry
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _context_pack_cache_key(
|
|
221
|
+
repo_root: Path,
|
|
222
|
+
seed_ref: str,
|
|
223
|
+
*,
|
|
224
|
+
mode: str,
|
|
225
|
+
profile: str,
|
|
226
|
+
changed_paths: list[str],
|
|
227
|
+
ordered_docs: list[WorkflowDocModel],
|
|
228
|
+
) -> str:
|
|
229
|
+
payload = {
|
|
230
|
+
"repo_root": str(repo_root.resolve()),
|
|
231
|
+
"seed_ref": seed_ref,
|
|
232
|
+
"mode": mode,
|
|
233
|
+
"profile": profile,
|
|
234
|
+
"changed_paths": changed_paths,
|
|
235
|
+
"docs": [
|
|
236
|
+
{
|
|
237
|
+
"ref": doc.ref,
|
|
238
|
+
"kind": doc.kind,
|
|
239
|
+
"path": doc.path,
|
|
240
|
+
"schema_version": doc.schema_version,
|
|
241
|
+
"status": doc.indicators.get("Status", ""),
|
|
242
|
+
"linked_refs": {prefix: refs for prefix, refs in doc.refs.items() if refs},
|
|
243
|
+
}
|
|
244
|
+
for doc in ordered_docs
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
return __import__("hashlib").sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_context_pack(
|
|
251
|
+
repo_root: Path,
|
|
252
|
+
seed_ref: str,
|
|
253
|
+
*,
|
|
254
|
+
mode: str,
|
|
255
|
+
profile: str,
|
|
256
|
+
config: dict[str, object] | None = None,
|
|
257
|
+
) -> dict[str, object]:
|
|
258
|
+
docs = _load_workflow_docs(repo_root)
|
|
259
|
+
seed = docs.get(seed_ref)
|
|
260
|
+
if seed is None:
|
|
261
|
+
raise SystemExit(f"Unknown workflow ref `{seed_ref}`.")
|
|
262
|
+
ordered = _workflow_neighborhood(seed, docs)[: _context_profile_limit(profile)]
|
|
263
|
+
changed_paths = _git_changed_paths(repo_root) if mode == "diff-first" else []
|
|
264
|
+
cache_key = _context_pack_cache_key(
|
|
265
|
+
repo_root,
|
|
266
|
+
seed_ref,
|
|
267
|
+
mode=mode,
|
|
268
|
+
profile=profile,
|
|
269
|
+
changed_paths=changed_paths,
|
|
270
|
+
ordered_docs=ordered,
|
|
271
|
+
)
|
|
272
|
+
cached_pack = _CONTEXT_PACK_CACHE.get(cache_key)
|
|
273
|
+
if isinstance(cached_pack, dict):
|
|
274
|
+
return deepcopy(cached_pack)
|
|
275
|
+
pack_docs = [_context_pack_doc_entry(doc, mode) for doc in ordered]
|
|
276
|
+
payload = {
|
|
277
|
+
"ref": seed_ref,
|
|
278
|
+
"mode": mode,
|
|
279
|
+
"profile": profile,
|
|
280
|
+
"budgets": {"max_docs": _context_profile_limit(profile)},
|
|
281
|
+
"changed_paths": changed_paths,
|
|
282
|
+
"docs": pack_docs,
|
|
283
|
+
"estimates": {
|
|
284
|
+
"doc_count": len(pack_docs),
|
|
285
|
+
"char_count": sum(len(json.dumps(entry, sort_keys=True)) for entry in pack_docs),
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
_CONTEXT_PACK_CACHE[cache_key] = deepcopy(payload)
|
|
289
|
+
return payload
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _resolve_target_docs(repo_root: Path, sources: list[str]) -> list[tuple[str, Path]]:
|
|
293
|
+
if not sources:
|
|
294
|
+
targets: list[tuple[str, Path]] = []
|
|
295
|
+
for kind_name, kind in DOC_KINDS.items():
|
|
296
|
+
directory = repo_root / kind["directory"]
|
|
297
|
+
if not directory.is_dir():
|
|
298
|
+
continue
|
|
299
|
+
for path in sorted(directory.glob(f"{kind['prefix']}_*.md")):
|
|
300
|
+
targets.append((kind_name, path))
|
|
301
|
+
return targets
|
|
302
|
+
|
|
303
|
+
resolved: list[tuple[str, Path]] = []
|
|
304
|
+
for source in sources:
|
|
305
|
+
candidate = (repo_root / source).resolve()
|
|
306
|
+
if candidate.is_file():
|
|
307
|
+
for kind_name, kind in DOC_KINDS.items():
|
|
308
|
+
if candidate.parent == (repo_root / kind["directory"]).resolve():
|
|
309
|
+
resolved.append((kind_name, candidate))
|
|
310
|
+
break
|
|
311
|
+
continue
|
|
312
|
+
for kind_name, kind in DOC_KINDS.items():
|
|
313
|
+
path = repo_root / kind["directory"] / f"{source}.md"
|
|
314
|
+
if path.is_file():
|
|
315
|
+
resolved.append((kind_name, path))
|
|
316
|
+
break
|
|
317
|
+
else:
|
|
318
|
+
raise SystemExit(f"Could not resolve workflow doc target `{source}`.")
|
|
319
|
+
return resolved
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _schema_status(repo_root: Path, targets: list[str]) -> dict[str, object]:
|
|
323
|
+
docs = [parse_workflow_doc(path, repo_root=repo_root) for _kind, path in _resolve_target_docs(repo_root, targets)]
|
|
324
|
+
counts: dict[str, int] = {}
|
|
325
|
+
outdated: list[str] = []
|
|
326
|
+
missing: list[str] = []
|
|
327
|
+
for doc in docs:
|
|
328
|
+
schema_version = doc.indicators.get("Schema version", "")
|
|
329
|
+
if not schema_version:
|
|
330
|
+
missing.append(doc.path)
|
|
331
|
+
schema_version = "(missing)"
|
|
332
|
+
counts[schema_version] = counts.get(schema_version, 0) + 1
|
|
333
|
+
if schema_version not in {"(missing)", "1.0"}:
|
|
334
|
+
outdated.append(doc.path)
|
|
335
|
+
return {
|
|
336
|
+
"current_schema_version": "1.0",
|
|
337
|
+
"counts": dict(sorted(counts.items())),
|
|
338
|
+
"missing": missing,
|
|
339
|
+
"outdated": outdated,
|
|
340
|
+
"doc_count": len(docs),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _graph_payload(repo_root: Path, *, config: dict[str, object] | None = None) -> dict[str, object]:
|
|
345
|
+
docs = _load_workflow_docs(repo_root)
|
|
346
|
+
nodes = []
|
|
347
|
+
edges = []
|
|
348
|
+
for doc in docs.values():
|
|
349
|
+
nodes.append(
|
|
350
|
+
{
|
|
351
|
+
"ref": doc.ref,
|
|
352
|
+
"kind": doc.kind,
|
|
353
|
+
"title": doc.title,
|
|
354
|
+
"path": doc.path,
|
|
355
|
+
"status": doc.indicators.get("Status", ""),
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
for refs in doc.refs.values():
|
|
359
|
+
for ref in refs:
|
|
360
|
+
if ref in docs:
|
|
361
|
+
edges.append({"from": doc.ref, "to": ref})
|
|
362
|
+
return {"nodes": nodes, "edges": edges}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _collect_docs_linking_ref(repo_root: Path, kind: str, ref: str) -> list[Path]:
|
|
366
|
+
directory = repo_root / DOC_KINDS[kind]["directory"]
|
|
367
|
+
linked: list[Path] = []
|
|
368
|
+
for path in sorted(directory.glob("*.md")):
|
|
369
|
+
if ref in _read_text(path):
|
|
370
|
+
linked.append(path)
|
|
371
|
+
return linked
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _is_doc_done(path: Path, kind: str) -> bool:
|
|
375
|
+
lines = _read_lines(path)
|
|
376
|
+
status_value = _indicator_value(lines, "Status")
|
|
377
|
+
if status_value is not None and " ".join(status_value.split()).lower() in {"done", "archived"}:
|
|
378
|
+
return True
|
|
379
|
+
if kind in {"backlog", "task"}:
|
|
380
|
+
progress_value = _indicator_value(lines, "Progress")
|
|
381
|
+
if progress_value is not None and progress_value.strip() == "100%":
|
|
382
|
+
return True
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _close_doc(path: Path, kind: str, dry_run: bool) -> None:
|
|
387
|
+
if dry_run:
|
|
388
|
+
return
|
|
389
|
+
lines = _read_lines(path)
|
|
390
|
+
updated = []
|
|
391
|
+
saw_status = False
|
|
392
|
+
saw_progress = False
|
|
393
|
+
for line in lines:
|
|
394
|
+
if line.startswith("> Status:"):
|
|
395
|
+
updated.append("> Status: Done")
|
|
396
|
+
saw_status = True
|
|
397
|
+
elif kind in {"backlog", "task"} and line.startswith("> Progress:"):
|
|
398
|
+
updated.append("> Progress: 100%")
|
|
399
|
+
saw_progress = True
|
|
400
|
+
else:
|
|
401
|
+
updated.append(line)
|
|
402
|
+
if not saw_status:
|
|
403
|
+
updated.insert(1, "> Status: Done")
|
|
404
|
+
if kind in {"backlog", "task"} and not saw_progress:
|
|
405
|
+
insert_at = 2 if saw_status else 3
|
|
406
|
+
updated.insert(insert_at, "> Progress: 100%")
|
|
407
|
+
path.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _refresh_workflow_mermaid_signature_text(text: str, kind: str, *, repo_root: Path | None = None, dry_run: bool = False) -> tuple[str, bool]:
|
|
411
|
+
match = MERMAID_BLOCK_PATTERN.search(text)
|
|
412
|
+
if match is None:
|
|
413
|
+
return text, False
|
|
414
|
+
lines = text.splitlines()
|
|
415
|
+
title = _extract_title(lines)
|
|
416
|
+
if not title:
|
|
417
|
+
return text, False
|
|
418
|
+
expected_signature = expected_workflow_mermaid_signature(kind, lines)
|
|
419
|
+
if not expected_signature:
|
|
420
|
+
return text, False
|
|
421
|
+
block = match.group(1)
|
|
422
|
+
signature_match = MERMAID_SIGNATURE_PATTERN.search(block)
|
|
423
|
+
if signature_match is None:
|
|
424
|
+
return text, False
|
|
425
|
+
current = signature_match.group(1).strip()
|
|
426
|
+
if current == expected_signature:
|
|
427
|
+
return text, False
|
|
428
|
+
refreshed_block = MERMAID_SIGNATURE_PATTERN.sub(f"%% logics-signature: {expected_signature}", block, count=1)
|
|
429
|
+
refreshed_text = text[: match.start()] + "```mermaid\n" + refreshed_block + "\n```" + text[match.end() :]
|
|
430
|
+
return refreshed_text, True
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def refresh_workflow_mermaid_signature_file(path: Path, kind: str, dry_run: bool, *, repo_root: Path | None = None) -> bool:
|
|
434
|
+
original = _read_text(path)
|
|
435
|
+
refreshed, changed = _refresh_workflow_mermaid_signature_text(original, kind, repo_root=repo_root, dry_run=dry_run)
|
|
436
|
+
if not changed:
|
|
437
|
+
return False
|
|
438
|
+
if not dry_run:
|
|
439
|
+
path.write_text(refreshed.rstrip() + "\n", encoding="utf-8")
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _close_eligible_requests(repo_root: Path, dry_run: bool) -> tuple[int, int]:
|
|
444
|
+
request_dir = repo_root / DOC_KINDS["request"]["directory"]
|
|
445
|
+
closed = 0
|
|
446
|
+
scanned = 0
|
|
447
|
+
for request_path in sorted(request_dir.glob("req_*.md")):
|
|
448
|
+
scanned += 1
|
|
449
|
+
if _is_doc_done(request_path, "request"):
|
|
450
|
+
continue
|
|
451
|
+
request_ref = request_path.stem
|
|
452
|
+
linked_items = _collect_docs_linking_ref(repo_root, "backlog", request_ref)
|
|
453
|
+
if not linked_items:
|
|
454
|
+
continue
|
|
455
|
+
if all(_is_doc_done(item_path, "backlog") for item_path in linked_items):
|
|
456
|
+
_close_doc(request_path, "request", dry_run)
|
|
457
|
+
print(f"Auto-closed request {request_ref} (all linked backlog items are done).")
|
|
458
|
+
closed += 1
|
|
459
|
+
return scanned, closed
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
463
|
+
parser = argparse.ArgumentParser(
|
|
464
|
+
prog="logics-manager sync",
|
|
465
|
+
description="Synchronize workflow closure transitions.",
|
|
466
|
+
)
|
|
467
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
468
|
+
|
|
469
|
+
close_eligible = sub.add_parser("close-eligible-requests", help="Auto-close requests when all linked backlog items are done.")
|
|
470
|
+
close_eligible.add_argument("--format", choices=("text", "json"), default="text")
|
|
471
|
+
close_eligible.add_argument("--dry-run", action="store_true")
|
|
472
|
+
close_eligible.set_defaults(func=cmd_close_eligible_requests)
|
|
473
|
+
|
|
474
|
+
refresh_mermaid = sub.add_parser("refresh-mermaid-signatures", help="Refresh stale workflow Mermaid signatures without rewriting the full diagram body.")
|
|
475
|
+
refresh_mermaid.add_argument("--format", choices=("text", "json"), default="text")
|
|
476
|
+
refresh_mermaid.add_argument("--dry-run", action="store_true")
|
|
477
|
+
refresh_mermaid.set_defaults(func=cmd_refresh_mermaid_signatures)
|
|
478
|
+
|
|
479
|
+
schema_status = sub.add_parser("schema-status", help="Report schema-version coverage for workflow docs.")
|
|
480
|
+
schema_status.add_argument("sources", nargs="*", help="Optional workflow refs or paths to scope the scan.")
|
|
481
|
+
schema_status.add_argument("--format", choices=("text", "json"), default="text")
|
|
482
|
+
schema_status.set_defaults(func=cmd_schema_status)
|
|
483
|
+
|
|
484
|
+
context_pack = sub.add_parser("context-pack", help="Build a compact context pack from workflow docs.")
|
|
485
|
+
context_pack.add_argument("ref", help="Seed workflow ref for the context pack.")
|
|
486
|
+
context_pack.add_argument("--mode", choices=("summary-only", "diff-first", "full"), default="summary-only")
|
|
487
|
+
context_pack.add_argument("--profile", choices=("tiny", "normal", "deep"), default="normal")
|
|
488
|
+
context_pack.add_argument("--out", help="Write the JSON artifact to this relative path.")
|
|
489
|
+
context_pack.add_argument("--format", choices=("text", "json"), default="text")
|
|
490
|
+
context_pack.add_argument("--dry-run", action="store_true")
|
|
491
|
+
context_pack.set_defaults(func=cmd_context_pack)
|
|
492
|
+
|
|
493
|
+
export_graph = sub.add_parser("export-graph", help="Export workflow relationships as a machine-readable graph.")
|
|
494
|
+
export_graph.add_argument("--out", help="Write the JSON graph to this relative path.")
|
|
495
|
+
export_graph.add_argument("--format", choices=("text", "json"), default="text")
|
|
496
|
+
export_graph.add_argument("--dry-run", action="store_true")
|
|
497
|
+
export_graph.set_defaults(func=cmd_export_graph)
|
|
498
|
+
|
|
499
|
+
return parser
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def cmd_close_eligible_requests(args: argparse.Namespace) -> dict[str, object]:
|
|
503
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
504
|
+
scanned, closed = _close_eligible_requests(repo_root, args.dry_run)
|
|
505
|
+
payload = {
|
|
506
|
+
"command": "sync",
|
|
507
|
+
"kind": "close-eligible-requests",
|
|
508
|
+
"repo_root": repo_root.as_posix(),
|
|
509
|
+
"scanned": scanned,
|
|
510
|
+
"closed": closed,
|
|
511
|
+
"dry_run": args.dry_run,
|
|
512
|
+
}
|
|
513
|
+
if args.format == "json":
|
|
514
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
515
|
+
else:
|
|
516
|
+
print(f"Scanned {scanned} request(s); closed {closed}.")
|
|
517
|
+
return payload
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def cmd_refresh_mermaid_signatures(args: argparse.Namespace) -> dict[str, object]:
|
|
521
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
522
|
+
modified: list[str] = []
|
|
523
|
+
for kind in ("request", "backlog", "task"):
|
|
524
|
+
directory = repo_root / DOC_KINDS[kind]["directory"]
|
|
525
|
+
for path in sorted(directory.glob("*.md")):
|
|
526
|
+
if refresh_workflow_mermaid_signature_file(path, kind, args.dry_run, repo_root=repo_root):
|
|
527
|
+
modified.append(path.relative_to(repo_root).as_posix())
|
|
528
|
+
|
|
529
|
+
payload = {
|
|
530
|
+
"command": "sync",
|
|
531
|
+
"kind": "refresh-mermaid-signatures",
|
|
532
|
+
"repo_root": repo_root.as_posix(),
|
|
533
|
+
"modified_files": modified,
|
|
534
|
+
"dry_run": args.dry_run,
|
|
535
|
+
}
|
|
536
|
+
if args.format == "json":
|
|
537
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
538
|
+
else:
|
|
539
|
+
if args.dry_run:
|
|
540
|
+
print(f"Dry run: {len(modified)} Mermaid signature update(s) would be applied.")
|
|
541
|
+
else:
|
|
542
|
+
print(f"Refreshed Mermaid signatures in {len(modified)} workflow doc(s).")
|
|
543
|
+
for rel_path in modified:
|
|
544
|
+
print(f"- {rel_path}")
|
|
545
|
+
return payload
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def cmd_schema_status(args: argparse.Namespace) -> dict[str, object]:
|
|
549
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
550
|
+
payload = _schema_status(repo_root, args.sources)
|
|
551
|
+
if args.format == "json":
|
|
552
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
553
|
+
else:
|
|
554
|
+
print(f"Schema status: {payload['doc_count']} workflow doc(s) scanned.")
|
|
555
|
+
for version, count in payload["counts"].items():
|
|
556
|
+
print(f"- {version}: {count}")
|
|
557
|
+
return {"command": "sync", "kind": "schema-status", "repo_root": repo_root.as_posix(), **payload}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_context_pack(args: argparse.Namespace) -> dict[str, object]:
|
|
561
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
562
|
+
payload = _build_context_pack(repo_root, args.ref, mode=args.mode, profile=args.profile, config=None)
|
|
563
|
+
if args.out:
|
|
564
|
+
out_path = (repo_root / args.out).resolve()
|
|
565
|
+
serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
566
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
567
|
+
if not args.dry_run:
|
|
568
|
+
out_path.write_text(serialized, encoding="utf-8")
|
|
569
|
+
print(f"Wrote {out_path.relative_to(repo_root)}")
|
|
570
|
+
payload["output_path"] = out_path.relative_to(repo_root).as_posix()
|
|
571
|
+
else:
|
|
572
|
+
if args.format == "json":
|
|
573
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
574
|
+
else:
|
|
575
|
+
print(f"Context pack: {payload['ref']} ({payload['mode']}, {payload['profile']})")
|
|
576
|
+
print(f"- docs: {payload['estimates']['doc_count']}")
|
|
577
|
+
return {"command": "sync", "kind": "context-pack", "repo_root": repo_root.as_posix(), **payload}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def cmd_export_graph(args: argparse.Namespace) -> dict[str, object]:
|
|
581
|
+
repo_root = _find_repo_root(Path.cwd())
|
|
582
|
+
payload = _graph_payload(repo_root, config=None)
|
|
583
|
+
payload["repo_root"] = repo_root.as_posix()
|
|
584
|
+
if args.out:
|
|
585
|
+
out_path = (repo_root / args.out).resolve()
|
|
586
|
+
serialized = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
587
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
588
|
+
if not args.dry_run:
|
|
589
|
+
out_path.write_text(serialized, encoding="utf-8")
|
|
590
|
+
print(f"Wrote {out_path.relative_to(repo_root)}")
|
|
591
|
+
payload["output_path"] = out_path.relative_to(repo_root).as_posix()
|
|
592
|
+
else:
|
|
593
|
+
if args.format == "json":
|
|
594
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
595
|
+
else:
|
|
596
|
+
print(f"Graph: {len(payload['nodes'])} node(s), {len(payload['edges'])} edge(s).")
|
|
597
|
+
return {"command": "sync", "kind": "export-graph", "repo_root": repo_root.as_posix(), **payload}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def main(argv: list[str]) -> int:
|
|
601
|
+
parser = build_parser()
|
|
602
|
+
args = parser.parse_args(argv)
|
|
603
|
+
payload = args.func(args)
|
|
604
|
+
return 0 if isinstance(payload, dict) else 1
|