@blxzer/cursor-trellis 0.3.5 → 0.4.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/CHANGELOG.md +69 -0
- package/README.md +13 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/goal/index.d.ts +3 -0
- package/dist/commands/goal/index.d.ts.map +1 -0
- package/dist/commands/goal/index.js +159 -0
- package/dist/commands/goal/index.js.map +1 -0
- package/dist/commands/goal/review.d.ts +6 -0
- package/dist/commands/goal/review.d.ts.map +1 -0
- package/dist/commands/goal/review.js +28 -0
- package/dist/commands/goal/review.js.map +1 -0
- package/dist/commands/sdk/run.d.ts.map +1 -1
- package/dist/commands/sdk/run.js +2 -27
- package/dist/commands/sdk/run.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +5 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +51 -2
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/constants/paths.d.ts +4 -0
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +4 -0
- package/dist/constants/paths.js.map +1 -1
- package/dist/goal/action-packet.d.ts +89 -0
- package/dist/goal/action-packet.d.ts.map +1 -0
- package/dist/goal/action-packet.js +113 -0
- package/dist/goal/action-packet.js.map +1 -0
- package/dist/goal/audit.d.ts +14 -0
- package/dist/goal/audit.d.ts.map +1 -0
- package/dist/goal/audit.js +12 -0
- package/dist/goal/audit.js.map +1 -0
- package/dist/goal/constants.d.ts +26 -0
- package/dist/goal/constants.d.ts.map +1 -0
- package/dist/goal/constants.js +24 -0
- package/dist/goal/constants.js.map +1 -0
- package/dist/goal/paths.d.ts +8 -0
- package/dist/goal/paths.d.ts.map +1 -0
- package/dist/goal/paths.js +36 -0
- package/dist/goal/paths.js.map +1 -0
- package/dist/goal/preflight.d.ts +21 -0
- package/dist/goal/preflight.d.ts.map +1 -0
- package/dist/goal/preflight.js +97 -0
- package/dist/goal/preflight.js.map +1 -0
- package/dist/goal/review-seam.d.ts +44 -0
- package/dist/goal/review-seam.d.ts.map +1 -0
- package/dist/goal/review-seam.js +153 -0
- package/dist/goal/review-seam.js.map +1 -0
- package/dist/goal/reviewer-rules.d.ts +13 -0
- package/dist/goal/reviewer-rules.d.ts.map +1 -0
- package/dist/goal/reviewer-rules.js +153 -0
- package/dist/goal/reviewer-rules.js.map +1 -0
- package/dist/goal/reviewer.d.ts +9 -0
- package/dist/goal/reviewer.d.ts.map +1 -0
- package/dist/goal/reviewer.js +54 -0
- package/dist/goal/reviewer.js.map +1 -0
- package/dist/goal/runtime.d.ts +32 -0
- package/dist/goal/runtime.d.ts.map +1 -0
- package/dist/goal/runtime.js +242 -0
- package/dist/goal/runtime.js.map +1 -0
- package/dist/goal/sdk-client.d.ts +10 -0
- package/dist/goal/sdk-client.d.ts.map +1 -0
- package/dist/goal/sdk-client.js +34 -0
- package/dist/goal/sdk-client.js.map +1 -0
- package/dist/goal/state.d.ts +40 -0
- package/dist/goal/state.d.ts.map +1 -0
- package/dist/goal/state.js +37 -0
- package/dist/goal/state.js.map +1 -0
- package/dist/goal/task-triggers.d.ts +15 -0
- package/dist/goal/task-triggers.d.ts.map +1 -0
- package/dist/goal/task-triggers.js +23 -0
- package/dist/goal/task-triggers.js.map +1 -0
- package/dist/goal/walls.d.ts +12 -0
- package/dist/goal/walls.d.ts.map +1 -0
- package/dist/goal/walls.js +65 -0
- package/dist/goal/walls.js.map +1 -0
- package/dist/goal/worker-prompt.d.ts +13 -0
- package/dist/goal/worker-prompt.d.ts.map +1 -0
- package/dist/goal/worker-prompt.js +41 -0
- package/dist/goal/worker-prompt.js.map +1 -0
- package/dist/goal/worker.d.ts +42 -0
- package/dist/goal/worker.d.ts.map +1 -0
- package/dist/goal/worker.js +180 -0
- package/dist/goal/worker.js.map +1 -0
- package/dist/migrations/manifests/0.3.5.json +9 -0
- package/dist/migrations/manifests/0.3.6.json +9 -0
- package/dist/migrations/manifests/0.4.0.json +9 -0
- package/dist/templates/common/commands/continue.md +15 -3
- package/dist/templates/common/skills/check.md +7 -4
- package/dist/templates/cursor/agents/cstl-check.md +1 -0
- package/dist/templates/cursor/fixtures/expected-rules.d.ts.map +1 -1
- package/dist/templates/cursor/fixtures/expected-rules.js +6 -1
- package/dist/templates/cursor/fixtures/expected-rules.js.map +1 -1
- package/dist/templates/cursor/rules/cstl-cursor-modes.mdc +32 -0
- package/dist/templates/cursor/rules/cstl-triage.mdc +2 -4
- package/dist/templates/cursor/rules/retrieval-routing.mdc +26 -7
- package/dist/templates/markdown/index.d.ts +9 -0
- package/dist/templates/markdown/index.d.ts.map +1 -1
- package/dist/templates/markdown/index.js +9 -0
- package/dist/templates/markdown/index.js.map +1 -1
- package/dist/templates/markdown/spec/guides/artifact-locale-guide.md.txt +93 -0
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +7 -7
- package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +38 -21
- package/dist/templates/markdown/spec/guides/cursor-native-modes-guide.md.txt +125 -0
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +10 -8
- package/dist/templates/markdown/spec/guides/debug-loop-guide.md.txt +227 -0
- package/dist/templates/markdown/spec/guides/goal-release-regression-runbook.md.txt +132 -0
- package/dist/templates/markdown/spec/guides/index.md.txt +144 -104
- package/dist/templates/markdown/spec/guides/injection-budget-guide.md.txt +111 -0
- package/dist/templates/markdown/spec/guides/prototype-guide.md.txt +139 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +28 -6
- package/dist/templates/markdown/spec/guides/test-discipline-guide.md.txt +138 -0
- package/dist/templates/markdown/spec/guides/verification-strength-guide.md.txt +185 -0
- package/dist/templates/shared-hooks/session-start.py +10 -0
- package/dist/templates/trellis/config.yaml +18 -0
- package/dist/templates/trellis/index.d.ts +17 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +40 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/pool/README.md +103 -0
- package/dist/templates/trellis/pool/items/.gitkeep +0 -0
- package/dist/templates/trellis/pool/plan.md +26 -0
- package/dist/templates/trellis/scripts/common/artifact_locale.py +278 -0
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +37 -2
- package/dist/templates/trellis/scripts/common/config.py +39 -0
- package/dist/templates/trellis/scripts/common/injection_budget.py +317 -0
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +161 -5
- package/dist/templates/trellis/scripts/common/pool_store.py +702 -0
- package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +9 -3
- package/dist/templates/trellis/scripts/common/session_context.py +32 -4
- package/dist/templates/trellis/scripts/common/session_memory.py +26 -0
- package/dist/templates/trellis/scripts/common/subagent_dispatch.py +74 -9
- package/dist/templates/trellis/scripts/common/task_context.py +18 -0
- package/dist/templates/trellis/scripts/common/task_dashboard.py +24 -1
- package/dist/templates/trellis/scripts/common/task_dependencies.py +673 -0
- package/dist/templates/trellis/scripts/common/task_gates.py +58 -5
- package/dist/templates/trellis/scripts/common/task_map.py +40 -8
- package/dist/templates/trellis/scripts/common/task_store.py +400 -39
- package/dist/templates/trellis/scripts/common/test_depends_mode_block.py +489 -0
- package/dist/templates/trellis/scripts/common/test_observable_defaults.py +135 -0
- package/dist/templates/trellis/scripts/common/test_pool_store.py +428 -0
- package/dist/templates/trellis/scripts/common/test_task_dependencies.py +345 -0
- package/dist/templates/trellis/scripts/cursor_retrieval_probe_prompt.md +14 -12
- package/dist/templates/trellis/scripts/generate_dispatch_prompt.py +182 -0
- package/dist/templates/trellis/scripts/injection_budget_probe.py +69 -0
- package/dist/templates/trellis/scripts/pool.py +192 -0
- package/dist/templates/trellis/scripts/retrieval_probe_matrix_template.json +3 -3
- package/dist/templates/trellis/scripts/spec_health_outcomes.py +160 -0
- package/dist/templates/trellis/scripts/task.py +85 -1
- package/dist/templates/trellis/scripts/verify_evidence_probe.py +138 -0
- package/dist/templates/trellis/tasks/locale/en/default-prd.md +19 -0
- package/dist/templates/trellis/tasks/locale/zh/default-prd.md +19 -0
- package/dist/templates/trellis/workflow.md +55 -5
- package/package.json +2 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Injection budget constants and measurement helpers (P0 channel matrix aligned)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
RULES_TOTAL_WARN_BYTES = 20_480
|
|
13
|
+
RULES_TOTAL_FAIL_BYTES = 32_768
|
|
14
|
+
AGENTS_CSTL_WARN_BYTES = 4_096
|
|
15
|
+
JSONL_MAX_ENTRIES = 8
|
|
16
|
+
JSONL_MAX_FILE_CHARS = 12_000
|
|
17
|
+
JSONL_MAX_TOTAL_CHARS = 48_000
|
|
18
|
+
|
|
19
|
+
DISPATCH_DEFAULT_MAX_CHARS: dict[str, int] = {
|
|
20
|
+
"implement": 96_000,
|
|
21
|
+
"check": 64_000,
|
|
22
|
+
"research": 48_000,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DispatchRole = Literal["implement", "check", "research"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RuleFileMeasure:
|
|
30
|
+
path: str
|
|
31
|
+
bytes: int
|
|
32
|
+
always_apply: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RulesMeasure:
|
|
37
|
+
files: list[RuleFileMeasure] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def always_apply_total_bytes(self) -> int:
|
|
41
|
+
return sum(f.bytes for f in self.files if f.always_apply)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AgentsMeasure:
|
|
46
|
+
path: str
|
|
47
|
+
total_bytes: int
|
|
48
|
+
cstl_block_bytes: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class JsonlMeasure:
|
|
53
|
+
jsonl_path: str
|
|
54
|
+
entry_count: int
|
|
55
|
+
expanded_chars: int
|
|
56
|
+
skipped: list[str] = field(default_factory=list)
|
|
57
|
+
loaded_files: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DispatchMeasure:
|
|
62
|
+
role: str
|
|
63
|
+
task_rel: str
|
|
64
|
+
prompt_chars: int
|
|
65
|
+
warnings: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class BudgetReport:
|
|
70
|
+
rules: RulesMeasure
|
|
71
|
+
agents: AgentsMeasure | None
|
|
72
|
+
jsonl: list[JsonlMeasure] = field(default_factory=list)
|
|
73
|
+
dispatch: DispatchMeasure | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_text_bytes(path: Path) -> tuple[str, int]:
|
|
77
|
+
data = path.read_bytes()
|
|
78
|
+
try:
|
|
79
|
+
text = data.decode("utf-8")
|
|
80
|
+
except UnicodeDecodeError:
|
|
81
|
+
text = data.decode("utf-8", errors="replace")
|
|
82
|
+
return text, len(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def measure_always_apply_rules(repo_root: Path) -> RulesMeasure:
|
|
86
|
+
rules_dir = repo_root / ".cursor" / "rules"
|
|
87
|
+
result = RulesMeasure()
|
|
88
|
+
if not rules_dir.is_dir():
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
for path in sorted(rules_dir.glob("*.mdc")):
|
|
92
|
+
text, nbytes = _read_text_bytes(path)
|
|
93
|
+
always = bool(re.search(r"^alwaysApply:\s*true\s*$", text, re.MULTILINE))
|
|
94
|
+
result.files.append(
|
|
95
|
+
RuleFileMeasure(path=str(path.relative_to(repo_root)), bytes=nbytes, always_apply=always)
|
|
96
|
+
)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def measure_agents_block(repo_root: Path) -> AgentsMeasure | None:
|
|
101
|
+
agents_path = repo_root / "AGENTS.md"
|
|
102
|
+
if not agents_path.is_file():
|
|
103
|
+
return None
|
|
104
|
+
text, nbytes = _read_text_bytes(agents_path)
|
|
105
|
+
cstl_bytes = 0
|
|
106
|
+
start = text.find("<!-- CSTL:START -->")
|
|
107
|
+
end = text.find("<!-- CSTL:END -->")
|
|
108
|
+
if start != -1 and end != -1 and end > start:
|
|
109
|
+
cstl_bytes = len(text[start : end + len("<!-- CSTL:END -->")].encode("utf-8"))
|
|
110
|
+
return AgentsMeasure(
|
|
111
|
+
path="AGENTS.md",
|
|
112
|
+
total_bytes=nbytes,
|
|
113
|
+
cstl_block_bytes=cstl_bytes,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _expand_jsonl_entry(repo_root: Path, file_path: str, entry_type: str) -> list[tuple[str, str]]:
|
|
118
|
+
if entry_type == "directory":
|
|
119
|
+
from .subagent_dispatch import read_directory_contents # lazy import
|
|
120
|
+
|
|
121
|
+
return read_directory_contents(str(repo_root), file_path)
|
|
122
|
+
from .subagent_dispatch import read_file_content
|
|
123
|
+
|
|
124
|
+
content = read_file_content(str(repo_root), file_path)
|
|
125
|
+
if content is None:
|
|
126
|
+
return []
|
|
127
|
+
return [(file_path, content)]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def measure_jsonl_manifest(
|
|
131
|
+
repo_root: Path,
|
|
132
|
+
task_dir: Path,
|
|
133
|
+
jsonl_name: str,
|
|
134
|
+
*,
|
|
135
|
+
apply_budget: bool = True,
|
|
136
|
+
) -> JsonlMeasure:
|
|
137
|
+
jsonl_path = task_dir / jsonl_name
|
|
138
|
+
rel_jsonl = str(jsonl_path.relative_to(repo_root)) if jsonl_path.is_file() else jsonl_name
|
|
139
|
+
measure = JsonlMeasure(jsonl_path=rel_jsonl, entry_count=0, expanded_chars=0)
|
|
140
|
+
if not jsonl_path.is_file():
|
|
141
|
+
return measure
|
|
142
|
+
|
|
143
|
+
entries: list[tuple[str, str]] = []
|
|
144
|
+
for line in jsonl_path.read_text(encoding="utf-8").splitlines():
|
|
145
|
+
line = line.strip()
|
|
146
|
+
if not line:
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
item = json.loads(line)
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
continue
|
|
152
|
+
file_path = item.get("file") or item.get("path")
|
|
153
|
+
if not file_path:
|
|
154
|
+
continue
|
|
155
|
+
entry_type = item.get("type", "file")
|
|
156
|
+
entries.append((file_path, entry_type))
|
|
157
|
+
|
|
158
|
+
measure.entry_count = len(entries)
|
|
159
|
+
if not apply_budget:
|
|
160
|
+
for file_path, entry_type in entries:
|
|
161
|
+
for rel, content in _expand_jsonl_entry(repo_root, file_path, entry_type):
|
|
162
|
+
measure.loaded_files.append(rel)
|
|
163
|
+
measure.expanded_chars += len(content)
|
|
164
|
+
return measure
|
|
165
|
+
|
|
166
|
+
total_chars = 0
|
|
167
|
+
loaded_count = 0
|
|
168
|
+
for file_path, entry_type in entries:
|
|
169
|
+
if loaded_count >= JSONL_MAX_ENTRIES:
|
|
170
|
+
measure.skipped.append(f"{file_path}: entry cap ({JSONL_MAX_ENTRIES})")
|
|
171
|
+
continue
|
|
172
|
+
expanded = _expand_jsonl_entry(repo_root, file_path, entry_type)
|
|
173
|
+
if not expanded:
|
|
174
|
+
measure.skipped.append(f"{file_path}: missing or empty")
|
|
175
|
+
continue
|
|
176
|
+
block_chars = sum(len(content) for _, content in expanded)
|
|
177
|
+
if block_chars > JSONL_MAX_FILE_CHARS:
|
|
178
|
+
measure.skipped.append(
|
|
179
|
+
f"{file_path}: file cap ({block_chars}>{JSONL_MAX_FILE_CHARS} chars)"
|
|
180
|
+
)
|
|
181
|
+
continue
|
|
182
|
+
if total_chars + block_chars > JSONL_MAX_TOTAL_CHARS:
|
|
183
|
+
measure.skipped.append(
|
|
184
|
+
f"{file_path}: total cap ({total_chars + block_chars}>{JSONL_MAX_TOTAL_CHARS})"
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
for rel, content in expanded:
|
|
188
|
+
measure.loaded_files.append(rel)
|
|
189
|
+
total_chars += len(content)
|
|
190
|
+
loaded_count += 1
|
|
191
|
+
|
|
192
|
+
measure.expanded_chars = total_chars
|
|
193
|
+
return measure
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def measure_dispatch_prompt(repo_root: Path, task_dir: Path, role: DispatchRole) -> DispatchMeasure | None:
|
|
197
|
+
from .subagent_dispatch import build_dispatch_prompt
|
|
198
|
+
|
|
199
|
+
prompt, warnings, errors = build_dispatch_prompt(
|
|
200
|
+
repo_root,
|
|
201
|
+
task_dir,
|
|
202
|
+
role,
|
|
203
|
+
max_chars=DISPATCH_DEFAULT_MAX_CHARS.get(role),
|
|
204
|
+
require_in_progress=False,
|
|
205
|
+
)
|
|
206
|
+
if not prompt:
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
task_rel = task_dir.relative_to(repo_root).as_posix()
|
|
210
|
+
except ValueError:
|
|
211
|
+
task_rel = task_dir.as_posix()
|
|
212
|
+
return DispatchMeasure(
|
|
213
|
+
role=role,
|
|
214
|
+
task_rel=task_rel,
|
|
215
|
+
prompt_chars=len(prompt),
|
|
216
|
+
warnings=list(warnings) + [f"error:{e}" for e in errors],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def collect_budget_report(
|
|
221
|
+
repo_root: Path,
|
|
222
|
+
*,
|
|
223
|
+
task_dir: Path | None = None,
|
|
224
|
+
dispatch_role: DispatchRole | None = None,
|
|
225
|
+
) -> BudgetReport:
|
|
226
|
+
report = BudgetReport(
|
|
227
|
+
rules=measure_always_apply_rules(repo_root),
|
|
228
|
+
agents=measure_agents_block(repo_root),
|
|
229
|
+
)
|
|
230
|
+
if task_dir and task_dir.is_dir():
|
|
231
|
+
for name in ("implement.jsonl", "check.jsonl"):
|
|
232
|
+
report.jsonl.append(measure_jsonl_manifest(repo_root, task_dir, name))
|
|
233
|
+
if dispatch_role:
|
|
234
|
+
report.dispatch = measure_dispatch_prompt(repo_root, task_dir, dispatch_role)
|
|
235
|
+
return report
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def budget_violations(report: BudgetReport, *, strict: bool = False) -> list[str]:
|
|
239
|
+
violations: list[str] = []
|
|
240
|
+
rules_total = report.rules.always_apply_total_bytes
|
|
241
|
+
if rules_total > RULES_TOTAL_WARN_BYTES:
|
|
242
|
+
level = "FAIL" if strict and rules_total > RULES_TOTAL_FAIL_BYTES else "WARN"
|
|
243
|
+
violations.append(
|
|
244
|
+
f"{level}: alwaysApply rules total {rules_total} B "
|
|
245
|
+
f"(warn>{RULES_TOTAL_WARN_BYTES}, fail>{RULES_TOTAL_FAIL_BYTES})"
|
|
246
|
+
)
|
|
247
|
+
elif strict and rules_total > RULES_TOTAL_WARN_BYTES:
|
|
248
|
+
violations.append(
|
|
249
|
+
f"WARN: alwaysApply rules total {rules_total} B (warn>{RULES_TOTAL_WARN_BYTES})"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if report.agents and report.agents.cstl_block_bytes > AGENTS_CSTL_WARN_BYTES:
|
|
253
|
+
violations.append(
|
|
254
|
+
f"WARN: AGENTS CSTL block {report.agents.cstl_block_bytes} B "
|
|
255
|
+
f"(warn>{AGENTS_CSTL_WARN_BYTES})"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
for jm in report.jsonl:
|
|
259
|
+
if jm.entry_count > JSONL_MAX_ENTRIES:
|
|
260
|
+
violations.append(
|
|
261
|
+
f"WARN: {jm.jsonl_path} has {jm.entry_count} entries (max {JSONL_MAX_ENTRIES})"
|
|
262
|
+
)
|
|
263
|
+
if jm.expanded_chars > JSONL_MAX_TOTAL_CHARS:
|
|
264
|
+
violations.append(
|
|
265
|
+
f"WARN: {jm.jsonl_path} expanded {jm.expanded_chars} chars "
|
|
266
|
+
f"(max {JSONL_MAX_TOTAL_CHARS})"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if report.dispatch:
|
|
270
|
+
cap = DISPATCH_DEFAULT_MAX_CHARS.get(report.dispatch.role, 0)
|
|
271
|
+
if cap and report.dispatch.prompt_chars > cap:
|
|
272
|
+
violations.append(
|
|
273
|
+
f"WARN: dispatch {report.dispatch.role} prompt {report.dispatch.prompt_chars} chars "
|
|
274
|
+
f"(default cap {cap})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return violations
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def format_budget_report(report: BudgetReport) -> str:
|
|
281
|
+
lines: list[str] = ["# Injection budget report", ""]
|
|
282
|
+
lines.append("## alwaysApply rules (.cursor/rules/*.mdc)")
|
|
283
|
+
for rf in report.rules.files:
|
|
284
|
+
flag = "alwaysApply" if rf.always_apply else "scoped"
|
|
285
|
+
lines.append(f"- {rf.path}: {rf.bytes} B ({flag})")
|
|
286
|
+
lines.append(f"- **Total alwaysApply:** {report.rules.always_apply_total_bytes} B")
|
|
287
|
+
lines.append("")
|
|
288
|
+
|
|
289
|
+
if report.agents:
|
|
290
|
+
lines.append("## AGENTS.md")
|
|
291
|
+
lines.append(f"- Total: {report.agents.total_bytes} B")
|
|
292
|
+
lines.append(f"- CSTL block: {report.agents.cstl_block_bytes} B")
|
|
293
|
+
lines.append("")
|
|
294
|
+
|
|
295
|
+
for jm in report.jsonl:
|
|
296
|
+
lines.append(f"## {jm.jsonl_path}")
|
|
297
|
+
lines.append(f"- Entries: {jm.entry_count}")
|
|
298
|
+
lines.append(f"- Expanded (budgeted): {jm.expanded_chars} chars")
|
|
299
|
+
if jm.loaded_files:
|
|
300
|
+
lines.append(f"- Loaded: {', '.join(jm.loaded_files)}")
|
|
301
|
+
if jm.skipped:
|
|
302
|
+
lines.append("- Skipped:")
|
|
303
|
+
for item in jm.skipped:
|
|
304
|
+
lines.append(f" - {item}")
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
if report.dispatch:
|
|
308
|
+
lines.append(f"## Layer 2 dispatch ({report.dispatch.role})")
|
|
309
|
+
lines.append(f"- Task: {report.dispatch.task_rel}")
|
|
310
|
+
lines.append(f"- Prompt chars: {report.dispatch.prompt_chars}")
|
|
311
|
+
if report.dispatch.warnings:
|
|
312
|
+
lines.append("- Warnings:")
|
|
313
|
+
for w in report.dispatch.warnings:
|
|
314
|
+
lines.append(f" - {w}")
|
|
315
|
+
lines.append("")
|
|
316
|
+
|
|
317
|
+
return "\n".join(lines)
|
|
@@ -12,6 +12,7 @@ import re
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
+
from .io import read_json
|
|
15
16
|
from .paths import get_repo_root
|
|
16
17
|
from .task_gates import (
|
|
17
18
|
build_spec_update_scaffold,
|
|
@@ -22,6 +23,7 @@ from .task_map import (
|
|
|
22
23
|
PARENT_TERMINAL_STATES,
|
|
23
24
|
get_child_entry,
|
|
24
25
|
load_task_map,
|
|
26
|
+
resolve_child_map_id,
|
|
25
27
|
validate_parent_child_integration,
|
|
26
28
|
)
|
|
27
29
|
|
|
@@ -100,7 +102,7 @@ def _child_prompt_section(
|
|
|
100
102
|
*,
|
|
101
103
|
include_artifacts: bool,
|
|
102
104
|
) -> str:
|
|
103
|
-
child_name = child_dir.name
|
|
105
|
+
child_name = child_entry.get("id") or child_dir.name
|
|
104
106
|
parent_rel = _repo_rel(parent_dir, repo_root)
|
|
105
107
|
child_rel = _repo_rel(child_dir, repo_root)
|
|
106
108
|
state = child_entry.get("state", "open")
|
|
@@ -164,12 +166,15 @@ def build_child_prompt(
|
|
|
164
166
|
"""Build an implementation prompt for a linked child task."""
|
|
165
167
|
errors: list[str] = []
|
|
166
168
|
repo_root = get_repo_root()
|
|
167
|
-
child_name = child_dir.name
|
|
168
169
|
|
|
169
170
|
parent_json = parent_dir / "task.json"
|
|
170
171
|
if not parent_json.is_file():
|
|
171
172
|
return None, [f"parent task.json missing: {parent_dir}"]
|
|
172
173
|
|
|
174
|
+
parent_data = read_json(parent_json) or {}
|
|
175
|
+
child_data = read_json(child_dir / "task.json") or {}
|
|
176
|
+
child_name = resolve_child_map_id(parent_data, child_dir, child_data)
|
|
177
|
+
|
|
173
178
|
data, body = load_task_map(parent_dir)
|
|
174
179
|
if data is None:
|
|
175
180
|
return None, ["parent task-map.md missing or invalid"]
|
|
@@ -360,6 +365,141 @@ def _unit_readiness(
|
|
|
360
365
|
return ("ready" if not blocked else "blocked", blocked)
|
|
361
366
|
|
|
362
367
|
|
|
368
|
+
_PACK_GENERATED_RE = re.compile(r"^- Generated:\s*(.+)$", re.MULTILINE)
|
|
369
|
+
_PACK_DASH = r"[\u2014\-]"
|
|
370
|
+
_PACK_UNIT_LINE_RE = re.compile(
|
|
371
|
+
rf"^- `([^`]+)` {_PACK_DASH} `([^`]+)` {_PACK_DASH} \*\*(ready|blocked)\*\*",
|
|
372
|
+
re.MULTILINE,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _parse_iso_timestamp(raw: str) -> datetime | None:
|
|
377
|
+
text = raw.strip()
|
|
378
|
+
if not text:
|
|
379
|
+
return None
|
|
380
|
+
normalized = text[:-1] + "+00:00" if text.endswith("Z") else text
|
|
381
|
+
try:
|
|
382
|
+
parsed = datetime.fromisoformat(normalized)
|
|
383
|
+
except ValueError:
|
|
384
|
+
try:
|
|
385
|
+
parsed = datetime.strptime(text, "%Y-%m-%d")
|
|
386
|
+
except ValueError:
|
|
387
|
+
return None
|
|
388
|
+
if parsed.tzinfo is None:
|
|
389
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
390
|
+
return parsed
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _parse_pack_generated(pack_text: str) -> datetime | None:
|
|
394
|
+
match = _PACK_GENERATED_RE.search(pack_text)
|
|
395
|
+
if not match:
|
|
396
|
+
return None
|
|
397
|
+
return _parse_iso_timestamp(match.group(1))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _last_integrate_event_time(body: str) -> datetime | None:
|
|
401
|
+
last: datetime | None = None
|
|
402
|
+
for line in body.splitlines():
|
|
403
|
+
stripped = line.strip()
|
|
404
|
+
if not stripped.startswith("- "):
|
|
405
|
+
continue
|
|
406
|
+
remainder = stripped[2:]
|
|
407
|
+
parts = remainder.split(" - ", 1)
|
|
408
|
+
if len(parts) != 2:
|
|
409
|
+
continue
|
|
410
|
+
ts_raw, message = parts[0].strip(), parts[1]
|
|
411
|
+
lowered = message.lower()
|
|
412
|
+
if "integration state" not in message and "integrate-through" not in lowered:
|
|
413
|
+
continue
|
|
414
|
+
parsed = _parse_iso_timestamp(ts_raw)
|
|
415
|
+
if parsed is not None and (last is None or parsed > last):
|
|
416
|
+
last = parsed
|
|
417
|
+
return last
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _pack_ready_units(pack_text: str) -> set[str]:
|
|
421
|
+
ready: set[str] = set()
|
|
422
|
+
for unit_id, _state, readiness in _PACK_UNIT_LINE_RE.findall(pack_text):
|
|
423
|
+
if readiness == "ready":
|
|
424
|
+
ready.add(unit_id)
|
|
425
|
+
return ready
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _expected_pack_ready_units(
|
|
429
|
+
data: dict,
|
|
430
|
+
children_by_id: dict[str, dict],
|
|
431
|
+
) -> set[str]:
|
|
432
|
+
stages, _, _ = _normalize_stages(data)
|
|
433
|
+
ready: set[str] = set()
|
|
434
|
+
for stage in stages:
|
|
435
|
+
for unit in stage.get("units") or []:
|
|
436
|
+
if not isinstance(unit, str):
|
|
437
|
+
continue
|
|
438
|
+
readiness, _ = _unit_readiness(unit, children_by_id)
|
|
439
|
+
if readiness == "ready":
|
|
440
|
+
ready.add(unit)
|
|
441
|
+
return ready
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _pack_unit_states_mismatch(
|
|
445
|
+
pack_text: str,
|
|
446
|
+
children_by_id: dict[str, dict],
|
|
447
|
+
) -> bool:
|
|
448
|
+
for unit_id, pack_state, _readiness in _PACK_UNIT_LINE_RE.findall(pack_text):
|
|
449
|
+
entry = children_by_id.get(unit_id) or {}
|
|
450
|
+
current_state = entry.get("state", "?")
|
|
451
|
+
if str(current_state) != pack_state:
|
|
452
|
+
return True
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _compute_stale_pack(
|
|
457
|
+
parent_dir: Path,
|
|
458
|
+
data: dict,
|
|
459
|
+
body: str,
|
|
460
|
+
children_by_id: dict[str, dict],
|
|
461
|
+
) -> bool | None:
|
|
462
|
+
pack_path = parent_dir / "child-prompts" / "PACK.md"
|
|
463
|
+
if not pack_path.is_file():
|
|
464
|
+
return None
|
|
465
|
+
try:
|
|
466
|
+
pack_text = pack_path.read_text(encoding="utf-8")
|
|
467
|
+
except OSError:
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
generated = _parse_pack_generated(pack_text)
|
|
471
|
+
if generated is None:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
stale = False
|
|
475
|
+
last_integrate = _last_integrate_event_time(body)
|
|
476
|
+
if last_integrate is not None and generated < last_integrate:
|
|
477
|
+
stale = True
|
|
478
|
+
if _pack_unit_states_mismatch(pack_text, children_by_id):
|
|
479
|
+
stale = True
|
|
480
|
+
expected_ready = _expected_pack_ready_units(data, children_by_id)
|
|
481
|
+
if _pack_ready_units(pack_text) != expected_ready:
|
|
482
|
+
stale = True
|
|
483
|
+
return stale
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _compute_newly_ready(children_by_id: dict[str, dict]) -> list[str]:
|
|
487
|
+
ready_ids: list[str] = []
|
|
488
|
+
for unit_id in sorted(children_by_id):
|
|
489
|
+
entry = children_by_id[unit_id]
|
|
490
|
+
if entry.get("state") != "open":
|
|
491
|
+
continue
|
|
492
|
+
readiness, _ = _unit_readiness(unit_id, children_by_id)
|
|
493
|
+
if readiness == "ready":
|
|
494
|
+
ready_ids.append(unit_id)
|
|
495
|
+
return ready_ids
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _child_prompt_filename(unit_id: str) -> str:
|
|
499
|
+
"""Flat filename for child-prompts (nested slash unit ids use `--`)."""
|
|
500
|
+
return f"{unit_id.replace('/', '--')}.md"
|
|
501
|
+
|
|
502
|
+
|
|
363
503
|
def build_publish_pack(
|
|
364
504
|
parent_dir: Path,
|
|
365
505
|
*,
|
|
@@ -442,7 +582,7 @@ def build_publish_pack(
|
|
|
442
582
|
if prompt_errors or not prompt:
|
|
443
583
|
errors.extend(prompt_errors or [f"could not build prompt for `{unit}`"])
|
|
444
584
|
continue
|
|
445
|
-
out_name =
|
|
585
|
+
out_name = _child_prompt_filename(unit)
|
|
446
586
|
out_rel = f"{parent_rel}/child-prompts/{out_name}"
|
|
447
587
|
if not dry_run:
|
|
448
588
|
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -470,7 +610,7 @@ def build_publish_pack(
|
|
|
470
610
|
"",
|
|
471
611
|
]
|
|
472
612
|
)
|
|
473
|
-
out_name =
|
|
613
|
+
out_name = _child_prompt_filename(unit)
|
|
474
614
|
out_rel = f"{parent_rel}/child-prompts/{out_name}"
|
|
475
615
|
if not dry_run:
|
|
476
616
|
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -539,6 +679,9 @@ def build_parent_status_dict(parent_dir: Path) -> dict | None:
|
|
|
539
679
|
|
|
540
680
|
children_by_id = _children_by_id(data)
|
|
541
681
|
stages, stage_errors, legacy = _normalize_stages(data)
|
|
682
|
+
_, body = load_task_map(parent_dir)
|
|
683
|
+
stale_pack = _compute_stale_pack(parent_dir, data, body, children_by_id)
|
|
684
|
+
newly_ready = _compute_newly_ready(children_by_id)
|
|
542
685
|
|
|
543
686
|
stage_rows: list[dict] = []
|
|
544
687
|
for stage in stages:
|
|
@@ -597,6 +740,8 @@ def build_parent_status_dict(parent_dir: Path) -> dict | None:
|
|
|
597
740
|
"integrationQueue": data.get("integration_queue") or [],
|
|
598
741
|
"stageErrors": stage_errors,
|
|
599
742
|
"legacyStages": legacy,
|
|
743
|
+
"stalePack": stale_pack,
|
|
744
|
+
"newlyReady": newly_ready,
|
|
600
745
|
}
|
|
601
746
|
|
|
602
747
|
|
|
@@ -620,6 +765,17 @@ def build_parent_status(parent_dir: Path) -> str:
|
|
|
620
765
|
|
|
621
766
|
children_by_id = _children_by_id(data)
|
|
622
767
|
stages, stage_errors, legacy = _normalize_stages(data)
|
|
768
|
+
_, body = load_task_map(parent_dir)
|
|
769
|
+
stale_pack = _compute_stale_pack(parent_dir, data, body, children_by_id)
|
|
770
|
+
newly_ready = _compute_newly_ready(children_by_id)
|
|
771
|
+
lines.append("## Pack freshness")
|
|
772
|
+
lines.append("")
|
|
773
|
+
lines.append(f"- stalePack: {stale_pack}")
|
|
774
|
+
if newly_ready:
|
|
775
|
+
lines.append(f"- newlyReady: {', '.join(newly_ready)}")
|
|
776
|
+
else:
|
|
777
|
+
lines.append("- newlyReady: (none)")
|
|
778
|
+
lines.append("")
|
|
623
779
|
lines.append("## Stages")
|
|
624
780
|
lines.append("")
|
|
625
781
|
if stage_errors:
|
|
@@ -790,7 +946,7 @@ def build_review_report(
|
|
|
790
946
|
notes: str | None,
|
|
791
947
|
) -> tuple[str, list[str], dict]:
|
|
792
948
|
"""Build review summary and validate integration transitions for a decision."""
|
|
793
|
-
child_name = child_dir
|
|
949
|
+
child_name = resolve_child_map_id(parent_data, child_dir, child_data)
|
|
794
950
|
summary = _summarize_handoff(child_dir)
|
|
795
951
|
errors: list[str] = []
|
|
796
952
|
actions: dict = {"integrate": None, "gates": []}
|