@seanyao/roll 3.609.2 → 3.610.1
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 +23 -1
- package/README.md +1 -2
- package/dist/roll.mjs +20572 -18445
- package/package.json +1 -2
- package/bin/roll +0 -15361
- package/lib/backfill-pi-usage.py +0 -243
- package/lib/changelog_audit.py +0 -149
- package/lib/changelog_generate.py +0 -470
- package/lib/consistency_check.py +0 -409
- package/lib/context_feed_budget.sh +0 -194
- package/lib/github_sync.py +0 -876
- package/lib/i18n.sh +0 -211
- package/lib/loop-exit-summary.py +0 -393
- package/lib/loop-fmt.py +0 -589
- package/lib/loop_pick_agent.py +0 -316
- package/lib/loop_result_eval.py +0 -469
- package/lib/loop_unstick.py +0 -180
- package/lib/model_prices.py +0 -194
- package/lib/prices_fetcher.py +0 -534
- package/lib/roll-backlog.py +0 -225
- package/lib/roll-brief.py +0 -286
- package/lib/roll-help.py +0 -158
- package/lib/roll-home.py +0 -556
- package/lib/roll-init.py +0 -156
- package/lib/roll-loop-status.py +0 -1691
- package/lib/roll-loop-story.py +0 -191
- package/lib/roll-peer.py +0 -252
- package/lib/roll-setup.py +0 -102
- package/lib/roll-status.py +0 -367
- package/lib/roll_git.py +0 -41
- package/lib/roll_render.py +0 -414
- package/lib/slides-render.py +0 -778
- package/lib/slides-validate.py +0 -357
- package/lib/test_quality_gate.py +0 -143
package/lib/consistency_check.py
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Consistency check orchestrator (US-CONSIST-001).
|
|
3
|
-
|
|
4
|
-
Runs checks across five dimensions, produces structured pass/gap reports.
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
python3 lib/consistency_check.py [--json] [--project-dir DIR]
|
|
8
|
-
|
|
9
|
-
Exit:
|
|
10
|
-
0 — all dimensions pass
|
|
11
|
-
1 — one or more dimensions have gaps
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import re
|
|
18
|
-
import sys
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
from typing import Any
|
|
21
|
-
|
|
22
|
-
DIMENSIONS = ["code", "docs", "i18n", "tests", "site"]
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _read_done_features(backlog_path: Path) -> dict[str, list[str]]:
|
|
26
|
-
"""Extract features with ≥1 ✅ Done story from backlog.
|
|
27
|
-
|
|
28
|
-
Returns {feature_name: [story_id, ...]}.
|
|
29
|
-
"""
|
|
30
|
-
text = backlog_path.read_text(encoding="utf-8")
|
|
31
|
-
features: dict[str, list[str]] = {}
|
|
32
|
-
current_feature: str | None = None
|
|
33
|
-
|
|
34
|
-
for line in text.splitlines():
|
|
35
|
-
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
36
|
-
if m:
|
|
37
|
-
current_feature = m.group(1).strip()
|
|
38
|
-
features[current_feature] = []
|
|
39
|
-
continue
|
|
40
|
-
|
|
41
|
-
if current_feature and "✅ Done" in line:
|
|
42
|
-
m2 = re.search(r"\[(US-|FIX-|REFACTOR-)([^\]]+)\]", line)
|
|
43
|
-
if m2:
|
|
44
|
-
features[current_feature].append(m2.group(1) + m2.group(2))
|
|
45
|
-
|
|
46
|
-
return {k: v for k, v in features.items() if v}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def check_features_catalog(project_dir: Path) -> dict[str, Any]:
|
|
50
|
-
"""Dimension: code — features.md catalog completeness.
|
|
51
|
-
|
|
52
|
-
Reuses logic from release.sh _enforce_features_catalog.
|
|
53
|
-
"""
|
|
54
|
-
backlog = project_dir / ".roll" / "backlog.md"
|
|
55
|
-
features = project_dir / ".roll" / "features.md"
|
|
56
|
-
|
|
57
|
-
if not backlog.exists() or not features.exists():
|
|
58
|
-
return {"status": "pass", "gaps": []}
|
|
59
|
-
|
|
60
|
-
done_features = _read_done_features(backlog)
|
|
61
|
-
if not done_features:
|
|
62
|
-
return {"status": "pass", "gaps": []}
|
|
63
|
-
|
|
64
|
-
features_text = features.read_text(encoding="utf-8")
|
|
65
|
-
gaps: list[str] = []
|
|
66
|
-
|
|
67
|
-
for feat_name in done_features:
|
|
68
|
-
escaped = re.escape(feat_name)
|
|
69
|
-
if not re.search(r"(^|[\s/])" + escaped + r"([\s/).]|$)", features_text):
|
|
70
|
-
gaps.append(
|
|
71
|
-
f"Feature '{feat_name}' has Done stories but is missing from features.md catalog"
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
"status": "pass" if not gaps else "fail",
|
|
76
|
-
"gaps": gaps,
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def check_site(project_dir: Path) -> dict[str, Any]:
|
|
81
|
-
"""Dimension: site — landing/介绍材料 ↔ backlog/features 一致性.
|
|
82
|
-
|
|
83
|
-
Parses site/roll-data.js FEATURE_GROUPS, compares against backlog Done features.
|
|
84
|
-
Read-only — does not modify site data.
|
|
85
|
-
"""
|
|
86
|
-
gaps: list[str] = []
|
|
87
|
-
site_js = project_dir / "site" / "roll-data.js"
|
|
88
|
-
backlog = project_dir / ".roll" / "backlog.md"
|
|
89
|
-
|
|
90
|
-
if not site_js.exists() or not backlog.exists():
|
|
91
|
-
return {"status": "pass", "gaps": []}
|
|
92
|
-
|
|
93
|
-
# Parse site feature names from FEATURE_GROUPS (both EN and ZH)
|
|
94
|
-
site_text = site_js.read_text(encoding="utf-8")
|
|
95
|
-
site_features: set[str] = set()
|
|
96
|
-
for m in re.finditer(r'\bname:\s*"([^"]+)"', site_text):
|
|
97
|
-
name = m.group(1).strip()
|
|
98
|
-
if name:
|
|
99
|
-
site_features.add(name)
|
|
100
|
-
|
|
101
|
-
if not site_features:
|
|
102
|
-
gaps.append(
|
|
103
|
-
"site/roll-data.js has no FEATURE_GROUPS feature names — "
|
|
104
|
-
"site may be missing content"
|
|
105
|
-
)
|
|
106
|
-
return {"status": "fail", "gaps": gaps}
|
|
107
|
-
|
|
108
|
-
# Normalize site feature names to search tokens
|
|
109
|
-
def _site_tokens(name: str) -> set[str]:
|
|
110
|
-
t = name.lower()
|
|
111
|
-
tokens: set[str] = set()
|
|
112
|
-
# Remove $ prefix and split on common delimiters
|
|
113
|
-
for part in re.split(r"[-/\s]+", t.lstrip("$")):
|
|
114
|
-
if len(part) > 1:
|
|
115
|
-
tokens.add(part)
|
|
116
|
-
return tokens
|
|
117
|
-
|
|
118
|
-
all_site_tokens: set[str] = set()
|
|
119
|
-
for name in site_features:
|
|
120
|
-
all_site_tokens.update(_site_tokens(name))
|
|
121
|
-
|
|
122
|
-
# Read backlog Done features
|
|
123
|
-
done_features = _read_done_features(backlog)
|
|
124
|
-
if not done_features:
|
|
125
|
-
return {"status": "pass", "gaps": []}
|
|
126
|
-
|
|
127
|
-
# Features that are internal infra / not user-facing; skip in site check
|
|
128
|
-
_internal_features = {
|
|
129
|
-
"cycle-meta-sync", "loop-log-locality", "invoke-stream-visibility",
|
|
130
|
-
"loop-done-semantics", "loop-status-reader-path", "loop-result-eval",
|
|
131
|
-
"loop-data-layout", "hooks-path-enforcement", "dev-vm-isolation",
|
|
132
|
-
"test-quality-gates", "tcr-test-strategy", "test-preconditions",
|
|
133
|
-
"e2e-lifecycle", "skill-harness", "agent-compliance",
|
|
134
|
-
"convention-management", "github-actions", "pr-lifecycle",
|
|
135
|
-
"loop-lifecycle-ownership", "loop-ci-self-heal",
|
|
136
|
-
"cycle-log-archive", "agent-aware-execution",
|
|
137
|
-
"manual-only-retirement", "loop-scheduling",
|
|
138
|
-
"context-feed-budget", "documentation", "github-issues-sync",
|
|
139
|
-
"notifications", "cycle-event-stream", "phase-tracing",
|
|
140
|
-
"loop-write-integrity", "cross-machine-sync", "remote-monitoring",
|
|
141
|
-
"cycle-history-rollup", "non-claude-usage-capture",
|
|
142
|
-
"loop-config-cli", "loop-exit-summary", "edit-render-fold",
|
|
143
|
-
"cli-redesign", "directory-restructure", "lifecycle-management",
|
|
144
|
-
"upstream-watch", "i18n-localization",
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
# For each Done feature, check if its keywords appear in site tokens
|
|
148
|
-
for feat_name in done_features:
|
|
149
|
-
# Skip features that are inherently internal / process-only
|
|
150
|
-
if feat_name in _internal_features:
|
|
151
|
-
continue
|
|
152
|
-
|
|
153
|
-
feat_tokens = _site_tokens(feat_name.replace("-", " "))
|
|
154
|
-
if not feat_tokens:
|
|
155
|
-
continue
|
|
156
|
-
|
|
157
|
-
# Feature is mentioned on site if at least half of its tokens match
|
|
158
|
-
match_count = sum(1 for t in feat_tokens if t in all_site_tokens)
|
|
159
|
-
if match_count < len(feat_tokens) / 2:
|
|
160
|
-
gaps.append(
|
|
161
|
-
f"Feature '{feat_name}' has Done stories but is not mentioned "
|
|
162
|
-
f"on the landing page — site may be missing this capability"
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
# Also check for stale site references: site features that reference
|
|
166
|
-
# commands no longer in scope
|
|
167
|
-
_cmds_referenced = {
|
|
168
|
-
"roll feedback", "roll release", "roll slides",
|
|
169
|
-
}
|
|
170
|
-
for cmd in _cmds_referenced:
|
|
171
|
-
if cmd in site_features and not any(
|
|
172
|
-
cmd.replace(" ", "-") in f for f in done_features
|
|
173
|
-
):
|
|
174
|
-
# Only flag if the feature is Done and the referenced command
|
|
175
|
-
# area has related Done features
|
|
176
|
-
pass # current coverage: all referenced commands have Done features
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
"status": "pass" if not gaps else "fail",
|
|
180
|
-
"gaps": gaps,
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def check_i18n(project_dir: Path) -> dict[str, Any]:
|
|
185
|
-
"""Dimension: i18n — guide file parity + i18n key completeness."""
|
|
186
|
-
gaps: list[str] = []
|
|
187
|
-
|
|
188
|
-
# 1. Guide file parity (guide/en ↔ guide/zh)
|
|
189
|
-
guide_en = project_dir / "guide" / "en"
|
|
190
|
-
guide_zh = project_dir / "guide" / "zh"
|
|
191
|
-
if guide_en.exists() and guide_zh.exists():
|
|
192
|
-
en_files = {p.name for p in guide_en.iterdir() if p.is_file()}
|
|
193
|
-
zh_files = {p.name for p in guide_zh.iterdir() if p.is_file()}
|
|
194
|
-
en_only = en_files - zh_files
|
|
195
|
-
zh_only = zh_files - en_files
|
|
196
|
-
for f in sorted(en_only):
|
|
197
|
-
gaps.append(f"guide/en/{f} has no corresponding guide/zh/{f}")
|
|
198
|
-
for f in sorted(zh_only):
|
|
199
|
-
gaps.append(f"guide/zh/{f} has no corresponding guide/en/{f}")
|
|
200
|
-
|
|
201
|
-
# 2. i18n key completeness (EN ↔ ZH pairing)
|
|
202
|
-
i18n_dir = project_dir / "lib" / "i18n"
|
|
203
|
-
if i18n_dir.exists():
|
|
204
|
-
keys_en: set[str] = set()
|
|
205
|
-
keys_zh: set[str] = set()
|
|
206
|
-
for sh_file in sorted(i18n_dir.glob("*.sh")):
|
|
207
|
-
text = sh_file.read_text(encoding="utf-8")
|
|
208
|
-
for m in re.finditer(r'_i18n_set\s+(en|zh)\s+([^\s]+)', text):
|
|
209
|
-
lang = m.group(1)
|
|
210
|
-
key = m.group(2)
|
|
211
|
-
if lang == "en":
|
|
212
|
-
keys_en.add(key)
|
|
213
|
-
else:
|
|
214
|
-
keys_zh.add(key)
|
|
215
|
-
en_only_keys = keys_en - keys_zh
|
|
216
|
-
zh_only_keys = keys_zh - keys_en
|
|
217
|
-
for k in sorted(en_only_keys):
|
|
218
|
-
gaps.append(f"i18n key '{k}' has EN but is missing ZH translation")
|
|
219
|
-
for k in sorted(zh_only_keys):
|
|
220
|
-
gaps.append(f"i18n key '{k}' has ZH but is missing EN translation")
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
"status": "pass" if not gaps else "fail",
|
|
224
|
-
"gaps": gaps,
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _feature_to_keywords(feature_name: str) -> list[str]:
|
|
229
|
-
"""Extract search keywords from a feature name for fuzzy matching."""
|
|
230
|
-
slug = feature_name.lower().replace("-", " ").replace("_", " ")
|
|
231
|
-
parts = [p for p in slug.split() if len(p) > 2]
|
|
232
|
-
return parts
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def _test_file_relates_to_feature(test_name: str, feature_name: str) -> bool:
|
|
236
|
-
"""Check if a test file name relates to a feature (fuzzy match)."""
|
|
237
|
-
keywords = _feature_to_keywords(feature_name)
|
|
238
|
-
if not keywords:
|
|
239
|
-
return False
|
|
240
|
-
test_lower = test_name.lower()
|
|
241
|
-
# Test is related if ALL feature keywords appear somewhere in the test name
|
|
242
|
-
return all(kw in test_lower for kw in keywords)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def check_tests(project_dir: Path) -> dict[str, Any]:
|
|
246
|
-
"""Dimension: tests — heuristic test coverage check.
|
|
247
|
-
|
|
248
|
-
Checks: (1) Done features have some test file that references them.
|
|
249
|
-
(2) Test files that reference non-existent features are flagged as stale.
|
|
250
|
-
"""
|
|
251
|
-
gaps: list[str] = []
|
|
252
|
-
backlog = project_dir / ".roll" / "backlog.md"
|
|
253
|
-
tests_dir = project_dir / "tests"
|
|
254
|
-
|
|
255
|
-
if not backlog.exists():
|
|
256
|
-
return {"status": "pass", "gaps": []}
|
|
257
|
-
|
|
258
|
-
# Read all features (Done or not) for stale-check and test-coverage baseline
|
|
259
|
-
backlog_text = backlog.read_text(encoding="utf-8")
|
|
260
|
-
all_features: set[str] = set()
|
|
261
|
-
done_features: list[str] = []
|
|
262
|
-
|
|
263
|
-
for line in backlog_text.splitlines():
|
|
264
|
-
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
265
|
-
if m:
|
|
266
|
-
current = m.group(1).strip()
|
|
267
|
-
all_features.add(current)
|
|
268
|
-
continue
|
|
269
|
-
if "✅ Done" in line:
|
|
270
|
-
# Get the feature from context or check if this feature has Done items
|
|
271
|
-
pass
|
|
272
|
-
|
|
273
|
-
# Re-scan to associate Done status to features
|
|
274
|
-
current_feature: str | None = None
|
|
275
|
-
for line in backlog_text.splitlines():
|
|
276
|
-
m = re.search(r"^### Feature:\s*(.+)$", line)
|
|
277
|
-
if m:
|
|
278
|
-
current_feature = m.group(1).strip()
|
|
279
|
-
continue
|
|
280
|
-
if current_feature and "✅ Done" in line:
|
|
281
|
-
m2 = re.search(r"\[(US-|FIX-|REFACTOR-)([^\]]+)\]", line)
|
|
282
|
-
if m2 and current_feature not in done_features:
|
|
283
|
-
done_features.append(current_feature)
|
|
284
|
-
|
|
285
|
-
# Collect test file names
|
|
286
|
-
test_files: list[str] = []
|
|
287
|
-
if tests_dir.exists():
|
|
288
|
-
for tf in tests_dir.rglob("*.bats"):
|
|
289
|
-
test_files.append(tf.name)
|
|
290
|
-
|
|
291
|
-
# If no test files exist at all, skip the check (not meaningful to flag gaps)
|
|
292
|
-
if not test_files:
|
|
293
|
-
return {"status": "pass", "gaps": []}
|
|
294
|
-
|
|
295
|
-
# 1. Check each Done feature for test coverage
|
|
296
|
-
for feat in done_features:
|
|
297
|
-
has_test = any(
|
|
298
|
-
_test_file_relates_to_feature(tf, feat) for tf in test_files
|
|
299
|
-
)
|
|
300
|
-
if not has_test:
|
|
301
|
-
gaps.append(
|
|
302
|
-
f"Feature '{feat}' has Done stories but no test file appears to cover it "
|
|
303
|
-
f"(heuristic: no test file name matches keywords "
|
|
304
|
-
f"{_feature_to_keywords(feat)})"
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
# 2. Check for stale test files (reference non-existent features)
|
|
308
|
-
for tf in test_files:
|
|
309
|
-
# Extract candidate feature name from test filename
|
|
310
|
-
# e.g., cmd_feedback.bats → feedback, agent_usage_pi.bats → (skip generic)
|
|
311
|
-
stem = tf.replace(".bats", "")
|
|
312
|
-
# Strip common test file prefixes
|
|
313
|
-
for prefix in ("cmd_", "agent_"):
|
|
314
|
-
if stem.startswith(prefix):
|
|
315
|
-
stem = stem[len(prefix):]
|
|
316
|
-
break
|
|
317
|
-
# Skip generic test files that don't map to a single feature
|
|
318
|
-
if "_" in stem or len(stem) < 4:
|
|
319
|
-
continue
|
|
320
|
-
# Convert to feature-name format: replace underscores with hyphens
|
|
321
|
-
candidate = stem.replace("_", "-")
|
|
322
|
-
if candidate not in all_features and stem not in all_features:
|
|
323
|
-
gaps.append(
|
|
324
|
-
f"Test file '{tf}' appears to reference feature '{candidate}' "
|
|
325
|
-
f"which does not exist in backlog — may be stale"
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
"status": "pass" if not gaps else "fail",
|
|
330
|
-
"gaps": gaps,
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def run_all(project_dir: Path) -> dict[str, Any]:
|
|
335
|
-
report: dict[str, Any] = {
|
|
336
|
-
"overall": "pass",
|
|
337
|
-
"dimensions": {},
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
for dim in DIMENSIONS:
|
|
341
|
-
if dim == "code":
|
|
342
|
-
result = check_features_catalog(project_dir)
|
|
343
|
-
elif dim == "i18n":
|
|
344
|
-
result = check_i18n(project_dir)
|
|
345
|
-
elif dim == "tests":
|
|
346
|
-
result = check_tests(project_dir)
|
|
347
|
-
elif dim == "docs":
|
|
348
|
-
result = {
|
|
349
|
-
"status": "pass",
|
|
350
|
-
"gaps": [],
|
|
351
|
-
"note": "placeholder — will be implemented in US-CONSIST-002",
|
|
352
|
-
}
|
|
353
|
-
elif dim == "site":
|
|
354
|
-
result = check_site(project_dir)
|
|
355
|
-
else:
|
|
356
|
-
result = {
|
|
357
|
-
"status": "pass",
|
|
358
|
-
"gaps": [],
|
|
359
|
-
"note": f"unknown dimension: {dim}",
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
report["dimensions"][dim] = result
|
|
363
|
-
if result["status"] == "fail":
|
|
364
|
-
report["overall"] = "fail"
|
|
365
|
-
|
|
366
|
-
return report
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def format_human(report: dict[str, Any]) -> str:
|
|
370
|
-
lines: list[str] = []
|
|
371
|
-
lines.append("Consistency Report")
|
|
372
|
-
lines.append("=" * 50)
|
|
373
|
-
|
|
374
|
-
for dim, result in report["dimensions"].items():
|
|
375
|
-
icon = "✅" if result["status"] == "pass" else "❌"
|
|
376
|
-
lines.append(f"{icon} {dim}: {result['status']}")
|
|
377
|
-
for gap in result.get("gaps", []):
|
|
378
|
-
lines.append(f" • {gap}")
|
|
379
|
-
note = result.get("note", "")
|
|
380
|
-
if note:
|
|
381
|
-
lines.append(f" ℹ {note}")
|
|
382
|
-
|
|
383
|
-
lines.append("-" * 50)
|
|
384
|
-
lines.append(f"Overall: {report['overall']}")
|
|
385
|
-
return "\n".join(lines)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def main() -> int:
|
|
389
|
-
parser = argparse.ArgumentParser(description="Consistency check orchestrator")
|
|
390
|
-
parser.add_argument(
|
|
391
|
-
"--json", action="store_true", help="Output machine-readable JSON"
|
|
392
|
-
)
|
|
393
|
-
parser.add_argument(
|
|
394
|
-
"--project-dir", type=Path, default=Path.cwd(), help="Project directory"
|
|
395
|
-
)
|
|
396
|
-
args = parser.parse_args()
|
|
397
|
-
|
|
398
|
-
report = run_all(args.project_dir)
|
|
399
|
-
|
|
400
|
-
if args.json:
|
|
401
|
-
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
402
|
-
else:
|
|
403
|
-
print(format_human(report))
|
|
404
|
-
|
|
405
|
-
return 0 if report["overall"] == "pass" else 1
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if __name__ == "__main__":
|
|
409
|
-
sys.exit(main())
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# US-CTX-001: Context-feed budget (投喂预算).
|
|
3
|
-
#
|
|
4
|
-
# roll is an outer orchestrator: when it builds the inner agent's prompt it
|
|
5
|
-
# injects material — chiefly the story's feature .md file. Large stories used to
|
|
6
|
-
# be fed whole ("整文件硬塞"), which can blow the inner agent's context window.
|
|
7
|
-
#
|
|
8
|
-
# This module is the ContextFeed aggregate. It owns FeedBudget (a max byte size,
|
|
9
|
-
# configurable) and decides an InjectionPlan (full / summarized / chunked) for a
|
|
10
|
-
# given piece of material — never silently truncating, always annotating when it
|
|
11
|
-
# summarizes or chunks and always pointing at the full-text path.
|
|
12
|
-
#
|
|
13
|
-
# Boundary: token-level compression stays in the inner agent harness. This module
|
|
14
|
-
# only answers "what to feed, and how much".
|
|
15
|
-
#
|
|
16
|
-
# Pure bash 3.2: no ${var^^}, no mapfile, no declare -A. All functions read from
|
|
17
|
-
# args / env and write to stdout — no global state, no file writes.
|
|
18
|
-
|
|
19
|
-
# Default feed budget in bytes. Tuned to comfortably hold a normal story feature
|
|
20
|
-
# file while staying well under an inner agent's context window. Configurable via
|
|
21
|
-
# ROLL_FEED_BUDGET_BYTES so operators can dial it to the inner agent's capacity.
|
|
22
|
-
ROLL_FEED_BUDGET_DEFAULT_BYTES=16384
|
|
23
|
-
|
|
24
|
-
# _feed_budget_bytes
|
|
25
|
-
# Resolve the active feed budget (bytes). Honors ROLL_FEED_BUDGET_BYTES when set
|
|
26
|
-
# to a positive integer; otherwise falls back to the compiled-in default.
|
|
27
|
-
_feed_budget_bytes() {
|
|
28
|
-
local v="${ROLL_FEED_BUDGET_BYTES:-}"
|
|
29
|
-
case "$v" in
|
|
30
|
-
''|*[!0-9]*) echo "$ROLL_FEED_BUDGET_DEFAULT_BYTES" ;;
|
|
31
|
-
*) if [ "$v" -gt 0 ]; then echo "$v"; else echo "$ROLL_FEED_BUDGET_DEFAULT_BYTES"; fi ;;
|
|
32
|
-
esac
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
# _feed_size_bytes <file>
|
|
36
|
-
# Byte size of a file. Echoes 0 for a missing/unreadable file.
|
|
37
|
-
_feed_size_bytes() {
|
|
38
|
-
local f="$1"
|
|
39
|
-
[ -f "$f" ] || { echo 0; return 0; }
|
|
40
|
-
# wc -c is portable across macOS bash 3.2 and Linux; strip leading spaces.
|
|
41
|
-
local n
|
|
42
|
-
n=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
|
|
43
|
-
case "$n" in
|
|
44
|
-
''|*[!0-9]*) echo 0 ;;
|
|
45
|
-
*) echo "$n" ;;
|
|
46
|
-
esac
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
# _feed_plan <file>
|
|
50
|
-
# Decide the InjectionPlan for a material file: prints one of
|
|
51
|
-
# full | summarized | chunked
|
|
52
|
-
# - Within budget → full
|
|
53
|
-
# - Over budget → summarized
|
|
54
|
-
# - Over 4x budget (huge) → chunked
|
|
55
|
-
# A missing file is treated as full (nothing to budget).
|
|
56
|
-
_feed_plan() {
|
|
57
|
-
local f="$1"
|
|
58
|
-
local size budget
|
|
59
|
-
size=$(_feed_size_bytes "$f")
|
|
60
|
-
budget=$(_feed_budget_bytes)
|
|
61
|
-
if [ "$size" -le "$budget" ]; then
|
|
62
|
-
echo full
|
|
63
|
-
elif [ "$size" -le "$((budget * 4))" ]; then
|
|
64
|
-
echo summarized
|
|
65
|
-
else
|
|
66
|
-
echo chunked
|
|
67
|
-
fi
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
# _feed_summary_notice <file> <plan>
|
|
71
|
-
# The explicit, non-silent annotation prepended to summarized/chunked material.
|
|
72
|
-
# Bilingual per project convention: EN and ZH on separate lines. Points at the
|
|
73
|
-
# full-text path so nothing is lost. Empty for the `full` plan.
|
|
74
|
-
_feed_summary_notice() {
|
|
75
|
-
local f="$1" plan="$2"
|
|
76
|
-
case "$plan" in
|
|
77
|
-
summarized)
|
|
78
|
-
printf '%s\n' "[context-feed] This story feature exceeds the feed budget — injected as a SUMMARY. Full text: ${f}"
|
|
79
|
-
printf '%s\n' "[投喂预算] 本故事 feature 超投喂预算,已摘要注入,全文见 ${f}"
|
|
80
|
-
;;
|
|
81
|
-
chunked)
|
|
82
|
-
printf '%s\n' "[context-feed] This story feature far exceeds the feed budget — injected in CHUNKS. Full text: ${f}"
|
|
83
|
-
printf '%s\n' "[投喂预算] 本故事 feature 远超投喂预算,已分段注入,全文见 ${f}"
|
|
84
|
-
;;
|
|
85
|
-
*) : ;;
|
|
86
|
-
esac
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
# _feed_budget_head <file>
|
|
90
|
-
# The leading <= budget bytes of <file>, trimmed back to the last COMPLETE line
|
|
91
|
-
# so we never cut mid-line silently. Keeps all whole lines that fit; if not even
|
|
92
|
-
# the first line fits, falls back to the raw byte head (a single very long line).
|
|
93
|
-
# Uses `dd` (not `head -c`) for portability across BSD/macOS and GNU coreutils.
|
|
94
|
-
_feed_budget_head() {
|
|
95
|
-
local f="$1"
|
|
96
|
-
local budget
|
|
97
|
-
budget=$(_feed_budget_bytes)
|
|
98
|
-
[ -f "$f" ] || return 0
|
|
99
|
-
local raw
|
|
100
|
-
raw=$(dd bs=1 count="$budget" if="$f" 2>/dev/null)
|
|
101
|
-
# Keep complete lines only. awk prints every line that ended with a newline
|
|
102
|
-
# within the byte window; the trailing partial line (no newline) is dropped.
|
|
103
|
-
# If awk yields nothing (the window is a single unterminated long line), keep
|
|
104
|
-
# the raw head so content is never silently emptied.
|
|
105
|
-
local trimmed
|
|
106
|
-
trimmed=$(printf '%s' "$raw" | awk '{
|
|
107
|
-
if (NR > 1) print buf
|
|
108
|
-
buf = $0
|
|
109
|
-
} END { }')
|
|
110
|
-
if [ -z "$trimmed" ]; then
|
|
111
|
-
printf '%s' "$raw"
|
|
112
|
-
else
|
|
113
|
-
printf '%s\n' "$trimmed"
|
|
114
|
-
fi
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
# _feed_summarize <file>
|
|
118
|
-
# Budget-fitting summary: the budget head (complete lines) plus an explicit,
|
|
119
|
-
# bilingual elision marker so the omission is never silent. Pure stdout.
|
|
120
|
-
_feed_summarize() {
|
|
121
|
-
local f="$1"
|
|
122
|
-
[ -f "$f" ] || return 0
|
|
123
|
-
_feed_budget_head "$f"
|
|
124
|
-
printf '%s\n' "[context-feed] ... summarized: tail elided, full text at the path noted above ..."
|
|
125
|
-
printf '%s\n' "[投喂预算] ……已摘要:尾部内容省略,全文见上方所注路径……"
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
# _feed_chunk_count <file>
|
|
129
|
-
# Number of budget-sized chunks the file spans (ceil(size / budget)), min 1.
|
|
130
|
-
_feed_chunk_count() {
|
|
131
|
-
local f="$1"
|
|
132
|
-
local size budget
|
|
133
|
-
size=$(_feed_size_bytes "$f")
|
|
134
|
-
budget=$(_feed_budget_bytes)
|
|
135
|
-
[ "$budget" -gt 0 ] || budget=1
|
|
136
|
-
local n=$(( (size + budget - 1) / budget ))
|
|
137
|
-
[ "$n" -lt 1 ] && n=1
|
|
138
|
-
echo "$n"
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
# _feed_chunk <file>
|
|
142
|
-
# Inject the FIRST budget-sized chunk (complete lines) with an explicit chunk
|
|
143
|
-
# header "chunk 1/N", so the slicing is real and labelled — not a mislabelled
|
|
144
|
-
# summary. Remaining chunks live in the full text at the noted path.
|
|
145
|
-
_feed_chunk() {
|
|
146
|
-
local f="$1"
|
|
147
|
-
[ -f "$f" ] || return 0
|
|
148
|
-
local n
|
|
149
|
-
n=$(_feed_chunk_count "$f")
|
|
150
|
-
printf '%s\n' "[context-feed] chunk 1/${n} (remaining chunks in full text at the path noted above):"
|
|
151
|
-
printf '%s\n' "[投喂预算] 第 1/${n} 段(其余段见上方所注路径全文):"
|
|
152
|
-
_feed_budget_head "$f"
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
# _feed_assemble <file>
|
|
156
|
-
# Top-level injector. Assembles the material to feed for <file> according to the
|
|
157
|
-
# active budget + plan, with explicit annotation for non-full plans. Pure stdout;
|
|
158
|
-
# callers capture this as the material to splice into the prompt.
|
|
159
|
-
_feed_assemble() {
|
|
160
|
-
local f="$1"
|
|
161
|
-
local plan
|
|
162
|
-
plan=$(_feed_plan "$f")
|
|
163
|
-
case "$plan" in
|
|
164
|
-
full)
|
|
165
|
-
[ -f "$f" ] && cat "$f"
|
|
166
|
-
;;
|
|
167
|
-
summarized)
|
|
168
|
-
_feed_summary_notice "$f" "$plan"
|
|
169
|
-
printf '\n'
|
|
170
|
-
_feed_summarize "$f"
|
|
171
|
-
;;
|
|
172
|
-
chunked)
|
|
173
|
-
_feed_summary_notice "$f" "$plan"
|
|
174
|
-
printf '\n'
|
|
175
|
-
_feed_chunk "$f"
|
|
176
|
-
;;
|
|
177
|
-
esac
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
# _feed_log_line <file> <plan>
|
|
181
|
-
# A single structured log line recording the actual INJECTED size + chosen
|
|
182
|
-
# strategy, for the event log. "Injected" = the byte count _feed_assemble emits
|
|
183
|
-
# (for full == source size; for summarized/chunked == the bounded material),
|
|
184
|
-
# satisfying the AC's "记录实际注入体积". Format is grep-friendly and stable:
|
|
185
|
-
# context_feed file=<f> strategy=<plan> bytes=<n> budget=<b>
|
|
186
|
-
_feed_log_line() {
|
|
187
|
-
local f="$1" plan="${2:-}"
|
|
188
|
-
[ -n "$plan" ] || plan=$(_feed_plan "$f")
|
|
189
|
-
local bytes budget
|
|
190
|
-
budget=$(_feed_budget_bytes)
|
|
191
|
-
bytes=$(_feed_assemble "$f" | wc -c | tr -d ' ')
|
|
192
|
-
case "$bytes" in ''|*[!0-9]*) bytes=0 ;; esac
|
|
193
|
-
printf 'context_feed file=%s strategy=%s bytes=%s budget=%s\n' "$f" "$plan" "$bytes" "$budget"
|
|
194
|
-
}
|