forge-workflow 0.0.3 → 0.0.5
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/.claude/commands/dev.md +340 -314
- package/.claude/commands/plan.md +521 -478
- package/.claude/commands/premerge.md +176 -179
- package/.claude/commands/research.md +42 -42
- package/.claude/commands/review.md +442 -442
- package/.claude/commands/rollback.md +721 -721
- package/.claude/commands/ship.md +164 -134
- package/.claude/commands/sonarcloud.md +152 -152
- package/.claude/commands/status.md +48 -77
- package/.claude/commands/validate.md +282 -237
- package/.claude/commands/verify.md +221 -221
- package/.claude/rules/greptile-review-process.md +285 -285
- package/.claude/rules/workflow.md +105 -105
- package/.claude/scripts/greptile-resolve.sh +526 -526
- package/.claude/scripts/load-env.sh +32 -32
- package/.cline/workflows/dev.md +337 -311
- package/.cline/workflows/plan.md +518 -475
- package/.cline/workflows/premerge.md +173 -176
- package/.cline/workflows/research.md +39 -39
- package/.cline/workflows/review.md +439 -439
- package/.cline/workflows/rollback.md +718 -718
- package/.cline/workflows/ship.md +161 -131
- package/.cline/workflows/sonarcloud.md +146 -146
- package/.cline/workflows/status.md +45 -74
- package/.cline/workflows/validate.md +279 -234
- package/.cline/workflows/verify.md +218 -218
- package/.codex/config.toml +11 -11
- package/.codex/skills/dev/SKILL.md +340 -314
- package/.codex/skills/plan/SKILL.md +521 -478
- package/.codex/skills/premerge/SKILL.md +176 -179
- package/.codex/skills/research/SKILL.md +42 -42
- package/.codex/skills/review/SKILL.md +442 -442
- package/.codex/skills/rollback/SKILL.md +721 -721
- package/.codex/skills/ship/SKILL.md +164 -134
- package/.codex/skills/sonarcloud/SKILL.md +149 -149
- package/.codex/skills/status/SKILL.md +48 -77
- package/.codex/skills/validate/SKILL.md +282 -237
- package/.codex/skills/verify/SKILL.md +221 -221
- package/.cursor/commands/dev.md +337 -311
- package/.cursor/commands/plan.md +518 -475
- package/.cursor/commands/premerge.md +173 -176
- package/.cursor/commands/research.md +39 -39
- package/.cursor/commands/review.md +439 -439
- package/.cursor/commands/rollback.md +718 -718
- package/.cursor/commands/ship.md +161 -131
- package/.cursor/commands/sonarcloud.md +146 -146
- package/.cursor/commands/status.md +45 -74
- package/.cursor/commands/validate.md +279 -234
- package/.cursor/commands/verify.md +218 -218
- package/.cursor/rules/permissions-guidance.mdc +37 -37
- package/.forge/hooks/check-tdd.js +240 -240
- package/.github/PLUGIN_TEMPLATE.json +32 -32
- package/.github/prompts/dev.prompt.md +342 -316
- package/.github/prompts/plan.prompt.md +523 -480
- package/.github/prompts/premerge.prompt.md +178 -181
- package/.github/prompts/research.prompt.md +44 -44
- package/.github/prompts/review.prompt.md +444 -444
- package/.github/prompts/rollback.prompt.md +723 -723
- package/.github/prompts/ship.prompt.md +166 -136
- package/.github/prompts/sonarcloud.prompt.md +151 -151
- package/.github/prompts/status.prompt.md +50 -79
- package/.github/prompts/validate.prompt.md +284 -239
- package/.github/prompts/verify.prompt.md +223 -223
- package/.github/workflows/beads-to-github.yml +56 -0
- package/.github/workflows/github-to-beads.yml +97 -0
- package/.kilocode/workflows/dev.md +341 -315
- package/.kilocode/workflows/plan.md +522 -479
- package/.kilocode/workflows/premerge.md +177 -180
- package/.kilocode/workflows/research.md +43 -43
- package/.kilocode/workflows/review.md +443 -443
- package/.kilocode/workflows/rollback.md +722 -722
- package/.kilocode/workflows/ship.md +165 -135
- package/.kilocode/workflows/sonarcloud.md +150 -150
- package/.kilocode/workflows/status.md +49 -78
- package/.kilocode/workflows/validate.md +283 -238
- package/.kilocode/workflows/verify.md +222 -222
- package/.mcp.json.example +12 -12
- package/.opencode/commands/dev.md +340 -314
- package/.opencode/commands/plan.md +521 -478
- package/.opencode/commands/premerge.md +176 -179
- package/.opencode/commands/research.md +42 -42
- package/.opencode/commands/review.md +442 -442
- package/.opencode/commands/rollback.md +721 -721
- package/.opencode/commands/ship.md +164 -134
- package/.opencode/commands/sonarcloud.md +149 -149
- package/.opencode/commands/status.md +48 -77
- package/.opencode/commands/validate.md +282 -237
- package/.opencode/commands/verify.md +221 -221
- package/.roo/commands/dev.md +341 -315
- package/.roo/commands/plan.md +522 -479
- package/.roo/commands/premerge.md +177 -180
- package/.roo/commands/research.md +43 -43
- package/.roo/commands/review.md +443 -443
- package/.roo/commands/rollback.md +722 -722
- package/.roo/commands/ship.md +165 -135
- package/.roo/commands/sonarcloud.md +150 -150
- package/.roo/commands/status.md +49 -78
- package/.roo/commands/validate.md +283 -238
- package/.roo/commands/verify.md +222 -222
- package/AGENTS.md +175 -169
- package/CLAUDE.md +100 -99
- package/LICENSE +21 -21
- package/README.md +429 -414
- package/bin/forge-cmd.js +313 -313
- package/bin/{forge-validate.js → forge-preflight.js} +309 -303
- package/bin/forge.js +4596 -4232
- package/docs/AGENT_INSTALL_PROMPT.md +342 -342
- package/docs/BEADS_GITHUB_SYNC.md +251 -0
- package/docs/ENHANCED_ONBOARDING.md +602 -602
- package/docs/EXAMPLES.md +482 -482
- package/docs/GREPTILE_SETUP.md +400 -400
- package/docs/MANUAL_REVIEW_GUIDE.md +106 -106
- package/docs/ROADMAP.md +359 -359
- package/docs/SETUP.md +663 -632
- package/docs/TOOLCHAIN.md +630 -630
- package/docs/VALIDATION.md +363 -363
- package/install.sh +40 -1058
- package/lefthook.yml +39 -39
- package/lib/agents/README.md +198 -198
- package/lib/agents/claude.plugin.json +28 -28
- package/lib/agents/cline.plugin.json +22 -22
- package/lib/agents/codex.plugin.json +19 -19
- package/lib/agents/copilot.plugin.json +24 -24
- package/lib/agents/cursor.plugin.json +25 -25
- package/lib/agents/kilocode.plugin.json +22 -22
- package/lib/agents/opencode.plugin.json +20 -20
- package/lib/agents/roo.plugin.json +23 -23
- package/lib/agents-config.js +2112 -2112
- package/lib/beads-health-check.js +143 -0
- package/lib/beads-setup.js +341 -0
- package/lib/beads-sync-scaffold.js +260 -0
- package/lib/commands/dev.js +513 -513
- package/lib/commands/plan.js +692 -692
- package/lib/commands/recommend.js +119 -119
- package/lib/commands/ship.js +377 -377
- package/lib/commands/status.js +378 -378
- package/lib/commands/validate.js +602 -602
- package/lib/context-merge.js +359 -359
- package/lib/dep-guard/analyzer.js +294 -294
- package/lib/dep-guard/behavior-detector.js +98 -98
- package/lib/dep-guard/contract-detector.js +162 -162
- package/lib/dep-guard/import-detector.js +498 -498
- package/lib/dep-guard/path-utils.js +13 -13
- package/lib/dep-guard/rubric.js +120 -120
- package/lib/dep-guard/task-parser.js +318 -318
- package/lib/detect-agent.js +191 -0
- package/lib/detect-worktree.js +47 -0
- package/lib/file-hash.js +26 -0
- package/lib/husky-migration.js +450 -0
- package/lib/lefthook-check.js +65 -0
- package/lib/pat-setup.js +207 -0
- package/lib/plugin-catalog.js +350 -350
- package/lib/plugin-manager.js +166 -166
- package/lib/plugin-recommender.js +141 -141
- package/lib/project-discovery.js +491 -491
- package/lib/setup-action-log.js +139 -0
- package/lib/setup-summary-renderer.js +106 -0
- package/lib/setup-utils.js +96 -0
- package/lib/setup.js +192 -118
- package/lib/smart-merge.js +64 -0
- package/lib/symlink-utils.js +81 -0
- package/lib/workflow-profiles.js +197 -197
- package/package.json +131 -129
- package/scripts/beads-context.sh +291 -0
- package/scripts/beads-context.test.js +563 -0
- package/scripts/behavioral-judge.sh +378 -0
- package/scripts/benchmark.js +85 -0
- package/scripts/branch-protection.js +183 -0
- package/scripts/check-agents.js +172 -0
- package/scripts/commitlint.js +42 -0
- package/scripts/conflict-detect.sh +323 -0
- package/scripts/dep-guard-analyze.js +71 -0
- package/scripts/dep-guard.sh +811 -0
- package/scripts/eval_win.py +249 -0
- package/scripts/file-index.sh +399 -0
- package/scripts/github-beads-sync/comment.mjs +64 -0
- package/scripts/github-beads-sync/config.mjs +148 -0
- package/scripts/github-beads-sync/github-api.mjs +131 -0
- package/scripts/github-beads-sync/index.mjs +332 -0
- package/scripts/github-beads-sync/label-mapper.mjs +54 -0
- package/scripts/github-beads-sync/mapping.mjs +78 -0
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +31 -0
- package/scripts/github-beads-sync/reverse-sync.mjs +138 -0
- package/scripts/github-beads-sync/run-bd.mjs +159 -0
- package/scripts/github-beads-sync/sanitize.mjs +121 -0
- package/scripts/github-beads-sync.config.json +26 -0
- package/scripts/improve-command.js +375 -0
- package/scripts/lib/eval-runner.js +229 -0
- package/scripts/lib/eval-schema.js +135 -0
- package/scripts/lib/eval-storage.js +78 -0
- package/scripts/lib/grading.js +203 -0
- package/scripts/lib/transcript-parser.js +63 -0
- package/scripts/lint.js +47 -0
- package/scripts/migrate-to-bun-test.js +412 -0
- package/scripts/run-command-eval.js +236 -0
- package/scripts/smart-status.sh +782 -0
- package/scripts/sync-commands.js +571 -0
- package/scripts/sync-utils.sh +460 -0
- package/scripts/test-dashboard.js +123 -0
- package/scripts/test.js +44 -0
- package/scripts/validate.sh +94 -0
- package/skills/parallel-deep-research/SKILL.md +108 -108
- package/skills/parallel-deep-research/evals/README.md +27 -27
- package/skills/parallel-deep-research/evals/evals.json +62 -62
- package/skills/sonarcloud-analysis/SKILL.md +171 -171
- package/skills/sonarcloud-analysis/evals/README.md +27 -27
- package/skills/sonarcloud-analysis/evals/evals.json +50 -50
- package/skills/sonarcloud-analysis/references/api-reference.md +466 -466
- package/docs/WORKFLOW.md +0 -400
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Windows-compatible trigger evaluation for skill descriptions.
|
|
3
|
+
|
|
4
|
+
Tests whether a skill's description causes Claude to trigger (invoke the Skill
|
|
5
|
+
tool for) the REAL skill when processing a query. Skills must be discoverable
|
|
6
|
+
in .claude/skills/ for this to work.
|
|
7
|
+
|
|
8
|
+
Avoids select.select() (Unix-only on pipes) and ProcessPoolExecutor (crashes
|
|
9
|
+
on Windows with paging file errors). Runs queries sequentially using
|
|
10
|
+
subprocess.communicate().
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_skill_md(skill_path: Path) -> tuple:
|
|
22
|
+
"""Parse a SKILL.md file, returning (name, description, full_content)."""
|
|
23
|
+
content = (skill_path / "SKILL.md").read_text(encoding="utf-8")
|
|
24
|
+
lines = content.split("\n")
|
|
25
|
+
|
|
26
|
+
if lines[0].strip() != "---":
|
|
27
|
+
raise ValueError("SKILL.md missing frontmatter (no opening ---)")
|
|
28
|
+
|
|
29
|
+
end_idx = None
|
|
30
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
31
|
+
if line.strip() == "---":
|
|
32
|
+
end_idx = i
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
if end_idx is None:
|
|
36
|
+
raise ValueError("SKILL.md missing frontmatter (no closing ---)")
|
|
37
|
+
|
|
38
|
+
name = ""
|
|
39
|
+
description = ""
|
|
40
|
+
frontmatter_lines = lines[1:end_idx]
|
|
41
|
+
i = 0
|
|
42
|
+
while i < len(frontmatter_lines):
|
|
43
|
+
line = frontmatter_lines[i]
|
|
44
|
+
if line.startswith("name:"):
|
|
45
|
+
name = line[len("name:"):].strip().strip('"').strip("'")
|
|
46
|
+
elif line.startswith("description:"):
|
|
47
|
+
value = line[len("description:"):].strip()
|
|
48
|
+
if value in (">", "|", ">-", "|-"):
|
|
49
|
+
continuation_lines = []
|
|
50
|
+
i += 1
|
|
51
|
+
while i < len(frontmatter_lines) and (
|
|
52
|
+
frontmatter_lines[i].startswith(" ")
|
|
53
|
+
or frontmatter_lines[i].startswith("\t")
|
|
54
|
+
):
|
|
55
|
+
continuation_lines.append(frontmatter_lines[i].strip())
|
|
56
|
+
i += 1
|
|
57
|
+
description = " ".join(continuation_lines)
|
|
58
|
+
continue
|
|
59
|
+
else:
|
|
60
|
+
description = value.strip('"').strip("'")
|
|
61
|
+
i += 1
|
|
62
|
+
|
|
63
|
+
return name, description, content
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_project_root() -> Path:
|
|
67
|
+
"""Find the project root by walking up from cwd looking for .claude/."""
|
|
68
|
+
current = Path.cwd()
|
|
69
|
+
for parent in [current, *current.parents]:
|
|
70
|
+
if (parent / ".claude").is_dir():
|
|
71
|
+
return parent
|
|
72
|
+
return current
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Known alias pairs: legacy command name → current skill name
|
|
76
|
+
_KNOWN_ALIASES = {
|
|
77
|
+
"sonarcloud-analysis": "sonarcloud",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_single_query(
|
|
82
|
+
query: str,
|
|
83
|
+
skill_name: str,
|
|
84
|
+
timeout: int,
|
|
85
|
+
project_root: str,
|
|
86
|
+
model=None,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Run a single query and return whether the REAL skill was triggered.
|
|
89
|
+
|
|
90
|
+
Checks if Claude invokes the Skill tool with the skill's name.
|
|
91
|
+
No temp command files — relies on .claude/skills/ discovery.
|
|
92
|
+
"""
|
|
93
|
+
cmd = [
|
|
94
|
+
"claude",
|
|
95
|
+
"-p", query,
|
|
96
|
+
"--output-format", "stream-json",
|
|
97
|
+
"--verbose",
|
|
98
|
+
]
|
|
99
|
+
if model:
|
|
100
|
+
cmd.extend(["--model", model])
|
|
101
|
+
|
|
102
|
+
env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
cmd,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
cwd=project_root,
|
|
110
|
+
env=env,
|
|
111
|
+
)
|
|
112
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Parse output for skill triggering — scan ALL assistant messages
|
|
117
|
+
for line in output.split("\n"):
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
event = json.loads(line)
|
|
123
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if event.get("type") == "assistant":
|
|
127
|
+
message = event.get("message", {})
|
|
128
|
+
for content_item in message.get("content", []):
|
|
129
|
+
if content_item.get("type") != "tool_use":
|
|
130
|
+
continue
|
|
131
|
+
tool_name = content_item.get("name", "")
|
|
132
|
+
tool_input = content_item.get("input", {})
|
|
133
|
+
if tool_name == "Skill":
|
|
134
|
+
invoked_skill = tool_input.get("skill", "")
|
|
135
|
+
if skill_name == invoked_skill:
|
|
136
|
+
return True
|
|
137
|
+
if invoked_skill == _KNOWN_ALIASES.get(skill_name):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main():
|
|
144
|
+
parser = argparse.ArgumentParser(
|
|
145
|
+
description="Windows-compatible trigger eval for skill descriptions"
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument("--eval-set", required=True, help="Path to eval set JSON")
|
|
148
|
+
parser.add_argument("--skill-path", required=True, help="Path to skill directory")
|
|
149
|
+
parser.add_argument("--timeout", type=int, default=60, help="Timeout per query (s)")
|
|
150
|
+
parser.add_argument("--runs-per-query", type=int, default=1, help="Runs per query")
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold"
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument("--model", default=None, help="Model override")
|
|
155
|
+
parser.add_argument("--verbose", action="store_true", help="Print progress")
|
|
156
|
+
args = parser.parse_args()
|
|
157
|
+
|
|
158
|
+
eval_set = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
|
|
159
|
+
skill_path = Path(args.skill_path)
|
|
160
|
+
|
|
161
|
+
if not (skill_path / "SKILL.md").exists():
|
|
162
|
+
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
name, description, _ = parse_skill_md(skill_path)
|
|
166
|
+
project_root = find_project_root()
|
|
167
|
+
|
|
168
|
+
# Warn if .claude/skills/ symlink is absent — skills won't be discoverable
|
|
169
|
+
skill_link = project_root / ".claude" / "skills" / name
|
|
170
|
+
if not skill_link.exists():
|
|
171
|
+
print(
|
|
172
|
+
f"Warning: .claude/skills/{name} not found — "
|
|
173
|
+
"skills not symlinked. Trigger rates will be 0%.\n"
|
|
174
|
+
"Run 'bunx skills sync' or create the symlink manually.",
|
|
175
|
+
file=sys.stderr,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if args.verbose:
|
|
179
|
+
print(f"Skill: {name}", file=sys.stderr)
|
|
180
|
+
print(f"Description: {description[:100]}...", file=sys.stderr)
|
|
181
|
+
print(f"Project root: {project_root}", file=sys.stderr)
|
|
182
|
+
print(f"Queries: {len(eval_set)}, Runs: {args.runs_per_query}", file=sys.stderr)
|
|
183
|
+
print("", file=sys.stderr)
|
|
184
|
+
|
|
185
|
+
results = []
|
|
186
|
+
for idx, item in enumerate(eval_set):
|
|
187
|
+
query = item["query"]
|
|
188
|
+
should_trigger = item["should_trigger"]
|
|
189
|
+
triggers = []
|
|
190
|
+
|
|
191
|
+
for run_idx in range(args.runs_per_query):
|
|
192
|
+
if args.verbose:
|
|
193
|
+
print(
|
|
194
|
+
f" [{idx+1}/{len(eval_set)}] run {run_idx+1}/{args.runs_per_query}: "
|
|
195
|
+
f"{query[:60]}...",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
triggered = run_single_query(
|
|
200
|
+
query, name, args.timeout, str(project_root), args.model
|
|
201
|
+
)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f" Warning: {e}", file=sys.stderr)
|
|
204
|
+
triggered = False
|
|
205
|
+
triggers.append(triggered)
|
|
206
|
+
|
|
207
|
+
trigger_rate = sum(triggers) / len(triggers) if triggers else 0.0
|
|
208
|
+
if should_trigger:
|
|
209
|
+
did_pass = trigger_rate >= args.trigger_threshold
|
|
210
|
+
else:
|
|
211
|
+
did_pass = trigger_rate < args.trigger_threshold
|
|
212
|
+
|
|
213
|
+
results.append(
|
|
214
|
+
{
|
|
215
|
+
"query": query,
|
|
216
|
+
"should_trigger": should_trigger,
|
|
217
|
+
"trigger_rate": trigger_rate,
|
|
218
|
+
"triggers": sum(triggers),
|
|
219
|
+
"runs": len(triggers),
|
|
220
|
+
"pass": did_pass,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if args.verbose:
|
|
225
|
+
status = "PASS" if did_pass else "FAIL"
|
|
226
|
+
print(
|
|
227
|
+
f" [{status}] rate={sum(triggers)}/{len(triggers)} "
|
|
228
|
+
f"expected={should_trigger}: {query[:70]}",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
passed = sum(1 for r in results if r["pass"])
|
|
233
|
+
total = len(results)
|
|
234
|
+
|
|
235
|
+
output = {
|
|
236
|
+
"skill_name": name,
|
|
237
|
+
"description": description,
|
|
238
|
+
"results": results,
|
|
239
|
+
"summary": {"total": total, "passed": passed, "failed": total - passed},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if args.verbose:
|
|
243
|
+
print(f"\nResults: {passed}/{total} passed", file=sys.stderr)
|
|
244
|
+
|
|
245
|
+
print(json.dumps(output, indent=2))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# file-index.sh — File index JSONL helpers for multi-developer awareness.
|
|
3
|
+
#
|
|
4
|
+
# Manages .beads/file-index.jsonl — an append-only log of which developer
|
|
5
|
+
# is working on which files/modules, keyed by issue_id.
|
|
6
|
+
#
|
|
7
|
+
# Functions (source this file):
|
|
8
|
+
# file_index_add <issue_id> <developer> <files_json> <modules_json>
|
|
9
|
+
# file_index_remove <issue_id>
|
|
10
|
+
# file_index_read
|
|
11
|
+
# file_index_get <issue_id>
|
|
12
|
+
# file_index_update_from_tasks <issue_id> <task_file_path> [action]
|
|
13
|
+
#
|
|
14
|
+
# Uses jq for all JSON construction (never string concatenation).
|
|
15
|
+
# OWASP A03: All inputs validated and shell-injection patterns stripped.
|
|
16
|
+
|
|
17
|
+
# Only set errexit/pipefail when run as a script, not when sourced
|
|
18
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# Resolve the JSONL file path.
|
|
25
|
+
# FILE_INDEX_ROOT can be overridden for testing.
|
|
26
|
+
_file_index_path() {
|
|
27
|
+
local root="${FILE_INDEX_ROOT:-.}"
|
|
28
|
+
printf '%s' "$root/.beads/file-index.jsonl"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Sanitize a string: strip shell-injection patterns (OWASP A03)
|
|
32
|
+
# Reused from dep-guard.sh pattern.
|
|
33
|
+
# Removes: double quotes, $(...), backticks, semicolons, and newlines
|
|
34
|
+
sanitize() {
|
|
35
|
+
local val="$1"
|
|
36
|
+
# Remove double quotes
|
|
37
|
+
val="${val//\"/}"
|
|
38
|
+
# Remove $(...) command substitution patterns (loop handles nested)
|
|
39
|
+
val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
|
|
40
|
+
# Remove backtick command substitution
|
|
41
|
+
val="${val//\`/}"
|
|
42
|
+
# Remove semicolons (command chaining)
|
|
43
|
+
val="${val//;/}"
|
|
44
|
+
# Replace newlines with spaces
|
|
45
|
+
val="$(printf '%s' "$val" | tr '\n' ' ')"
|
|
46
|
+
printf '%s' "$val"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Validate issue_id: alphanumeric + hyphens only
|
|
50
|
+
_validate_issue_id() {
|
|
51
|
+
local id="$1"
|
|
52
|
+
if [[ ! "$id" =~ ^[a-zA-Z0-9-]+$ ]]; then
|
|
53
|
+
echo "Error: invalid issue_id format: must be alphanumeric + hyphens only" >&2
|
|
54
|
+
return 1
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Validate developer identity: ^[a-zA-Z0-9._@+-]+$
|
|
59
|
+
_validate_developer() {
|
|
60
|
+
local dev="$1"
|
|
61
|
+
if [[ ! "$dev" =~ ^[a-zA-Z0-9._@+\-]+$ ]]; then
|
|
62
|
+
echo "Error: invalid developer format: must match ^[a-zA-Z0-9._@+-]+$" >&2
|
|
63
|
+
return 1
|
|
64
|
+
fi
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# ── Public functions ─────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
# file_index_add <issue_id> <developer> <files_json> <modules_json>
|
|
70
|
+
# Append an entry to the file index JSONL.
|
|
71
|
+
# files_json and modules_json must be valid JSON arrays.
|
|
72
|
+
file_index_add() {
|
|
73
|
+
local raw_issue_id="$1"
|
|
74
|
+
local raw_developer="$2"
|
|
75
|
+
local files_json="$3"
|
|
76
|
+
local modules_json="$4"
|
|
77
|
+
|
|
78
|
+
# Sanitize inputs
|
|
79
|
+
local issue_id
|
|
80
|
+
issue_id="$(sanitize "$raw_issue_id")"
|
|
81
|
+
# Trim whitespace produced by sanitize
|
|
82
|
+
issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
83
|
+
|
|
84
|
+
local developer
|
|
85
|
+
developer="$(sanitize "$raw_developer")"
|
|
86
|
+
developer="$(printf '%s' "$developer" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
87
|
+
|
|
88
|
+
# Validate formats (after sanitization — catches injection that was stripped)
|
|
89
|
+
_validate_issue_id "$issue_id" || return 1
|
|
90
|
+
_validate_developer "$developer" || return 1
|
|
91
|
+
|
|
92
|
+
# Validate files_json and modules_json are valid JSON arrays
|
|
93
|
+
if ! printf '%s' "$files_json" | jq 'if type == "array" then empty else error end' 2>/dev/null; then
|
|
94
|
+
echo "Error: files_json must be a valid JSON array" >&2
|
|
95
|
+
return 1
|
|
96
|
+
fi
|
|
97
|
+
if ! printf '%s' "$modules_json" | jq 'if type == "array" then empty else error end' 2>/dev/null; then
|
|
98
|
+
echo "Error: modules_json must be a valid JSON array" >&2
|
|
99
|
+
return 1
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Generate timestamp
|
|
103
|
+
local updated_at
|
|
104
|
+
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
105
|
+
|
|
106
|
+
# Build JSON using jq (never string concatenation)
|
|
107
|
+
local json_line
|
|
108
|
+
json_line="$(jq -n -c \
|
|
109
|
+
--arg id "$issue_id" \
|
|
110
|
+
--arg dev "$developer" \
|
|
111
|
+
--argjson files "$files_json" \
|
|
112
|
+
--argjson modules "$modules_json" \
|
|
113
|
+
--arg ts "$updated_at" \
|
|
114
|
+
'{
|
|
115
|
+
issue_id: $id,
|
|
116
|
+
developer: $dev,
|
|
117
|
+
files: $files,
|
|
118
|
+
modules: $modules,
|
|
119
|
+
updated_at: $ts,
|
|
120
|
+
tombstone: false
|
|
121
|
+
}')"
|
|
122
|
+
|
|
123
|
+
# Append to JSONL file
|
|
124
|
+
local jsonl_path
|
|
125
|
+
jsonl_path="$(_file_index_path)"
|
|
126
|
+
|
|
127
|
+
# Ensure parent directory exists
|
|
128
|
+
mkdir -p "$(dirname "$jsonl_path")"
|
|
129
|
+
|
|
130
|
+
printf '%s\n' "$json_line" >> "$jsonl_path"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# file_index_remove <issue_id>
|
|
134
|
+
# Append a tombstone entry for the given issue.
|
|
135
|
+
file_index_remove() {
|
|
136
|
+
local raw_issue_id="$1"
|
|
137
|
+
|
|
138
|
+
# Sanitize and validate
|
|
139
|
+
local issue_id
|
|
140
|
+
issue_id="$(sanitize "$raw_issue_id")"
|
|
141
|
+
issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
142
|
+
_validate_issue_id "$issue_id" || return 1
|
|
143
|
+
|
|
144
|
+
# Generate timestamp
|
|
145
|
+
local updated_at
|
|
146
|
+
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
147
|
+
|
|
148
|
+
# Build tombstone JSON using jq
|
|
149
|
+
local json_line
|
|
150
|
+
json_line="$(jq -n -c \
|
|
151
|
+
--arg id "$issue_id" \
|
|
152
|
+
--arg ts "$updated_at" \
|
|
153
|
+
'{
|
|
154
|
+
issue_id: $id,
|
|
155
|
+
developer: "",
|
|
156
|
+
files: [],
|
|
157
|
+
modules: [],
|
|
158
|
+
updated_at: $ts,
|
|
159
|
+
tombstone: true
|
|
160
|
+
}')"
|
|
161
|
+
|
|
162
|
+
# Append to JSONL file
|
|
163
|
+
local jsonl_path
|
|
164
|
+
jsonl_path="$(_file_index_path)"
|
|
165
|
+
mkdir -p "$(dirname "$jsonl_path")"
|
|
166
|
+
printf '%s\n' "$json_line" >> "$jsonl_path"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# file_index_read
|
|
170
|
+
# Read all entries, resolve LWW per issue_id, output active entries as JSON array.
|
|
171
|
+
# Returns "[]" if the file is missing or empty.
|
|
172
|
+
file_index_read() {
|
|
173
|
+
local jsonl_path
|
|
174
|
+
jsonl_path="$(_file_index_path)"
|
|
175
|
+
|
|
176
|
+
# Handle missing or empty file
|
|
177
|
+
if [[ ! -f "$jsonl_path" ]] || [[ ! -s "$jsonl_path" ]]; then
|
|
178
|
+
printf '%s' "[]"
|
|
179
|
+
return 0
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
# Use jq to:
|
|
183
|
+
# 1. Slurp all lines into an array
|
|
184
|
+
# 2. Group by issue_id
|
|
185
|
+
# 3. For each group, sort by updated_at and take the last (LWW)
|
|
186
|
+
# 4. Filter out tombstoned entries
|
|
187
|
+
jq -s -c '
|
|
188
|
+
sort_by(.issue_id)
|
|
189
|
+
| group_by(.issue_id)
|
|
190
|
+
| map(sort_by(.updated_at) | last)
|
|
191
|
+
| map(select(.tombstone == false))
|
|
192
|
+
' "$jsonl_path"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# file_index_get <issue_id>
|
|
196
|
+
# Get a single issue's file entry (after LWW resolution).
|
|
197
|
+
# Returns "null" if not found or tombstoned.
|
|
198
|
+
file_index_get() {
|
|
199
|
+
local raw_issue_id="$1"
|
|
200
|
+
|
|
201
|
+
# Sanitize and validate
|
|
202
|
+
local issue_id
|
|
203
|
+
issue_id="$(sanitize "$raw_issue_id")"
|
|
204
|
+
issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
205
|
+
_validate_issue_id "$issue_id" || return 1
|
|
206
|
+
|
|
207
|
+
local jsonl_path
|
|
208
|
+
jsonl_path="$(_file_index_path)"
|
|
209
|
+
|
|
210
|
+
# Handle missing or empty file
|
|
211
|
+
if [[ ! -f "$jsonl_path" ]] || [[ ! -s "$jsonl_path" ]]; then
|
|
212
|
+
printf '%s' "null"
|
|
213
|
+
return 0
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
# Use jq to filter by issue_id, resolve LWW, check tombstone
|
|
217
|
+
jq -s -c --arg id "$issue_id" '
|
|
218
|
+
map(select(.issue_id == $id))
|
|
219
|
+
| sort_by(.updated_at)
|
|
220
|
+
| last
|
|
221
|
+
| if . == null then null
|
|
222
|
+
elif .tombstone == true then null
|
|
223
|
+
else .
|
|
224
|
+
end
|
|
225
|
+
' "$jsonl_path"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# file_index_update_from_tasks <issue_id> <task_file_path> [action]
|
|
229
|
+
# Parse a task file for File(s): lines, extract file paths, derive modules,
|
|
230
|
+
# and update the file index using the current session identity.
|
|
231
|
+
#
|
|
232
|
+
# action: "in_progress" (default) — adds/updates entry
|
|
233
|
+
# "closed" — appends tombstone via file_index_remove
|
|
234
|
+
#
|
|
235
|
+
# Edge cases:
|
|
236
|
+
# - Missing task file or no File(s): lines → fallback with confidence: "low"
|
|
237
|
+
# - File paths are sanitized against shell injection (OWASP A03)
|
|
238
|
+
file_index_update_from_tasks() {
|
|
239
|
+
local raw_issue_id="$1"
|
|
240
|
+
local task_file_path="${2:-}"
|
|
241
|
+
local action="${3:-in_progress}"
|
|
242
|
+
|
|
243
|
+
# Sanitize and validate issue_id
|
|
244
|
+
local issue_id
|
|
245
|
+
issue_id="$(sanitize "$raw_issue_id")"
|
|
246
|
+
issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
247
|
+
_validate_issue_id "$issue_id" || return 1
|
|
248
|
+
|
|
249
|
+
# Handle closed action: just tombstone
|
|
250
|
+
if [[ "$action" == "closed" ]]; then
|
|
251
|
+
file_index_remove "$issue_id"
|
|
252
|
+
return 0
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
# Get session identity (source sync-utils.sh if get_session_identity not defined)
|
|
256
|
+
local developer
|
|
257
|
+
if ! command -v get_session_identity &>/dev/null; then
|
|
258
|
+
local script_dir
|
|
259
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
260
|
+
if [[ -f "$script_dir/sync-utils.sh" ]]; then
|
|
261
|
+
source "$script_dir/sync-utils.sh"
|
|
262
|
+
fi
|
|
263
|
+
fi
|
|
264
|
+
developer="$(get_session_identity)" || {
|
|
265
|
+
echo "Error: could not determine session identity" >&2
|
|
266
|
+
return 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# Parse task file for File(s): lines
|
|
270
|
+
local -a raw_files=()
|
|
271
|
+
local confidence="high"
|
|
272
|
+
|
|
273
|
+
if [[ -z "$task_file_path" ]] || [[ ! -f "$task_file_path" ]]; then
|
|
274
|
+
# Fallback: no task file
|
|
275
|
+
confidence="low"
|
|
276
|
+
else
|
|
277
|
+
# Extract backtick-quoted paths from File(s): lines (case-insensitive)
|
|
278
|
+
local line
|
|
279
|
+
while IFS= read -r line; do
|
|
280
|
+
# Extract all backtick-quoted strings from this line
|
|
281
|
+
local path
|
|
282
|
+
while [[ "$line" =~ \`([^\`]+)\` ]]; do
|
|
283
|
+
path="${BASH_REMATCH[1]}"
|
|
284
|
+
# Strip only trailing annotations like " (extend)" at end of path
|
|
285
|
+
# Anchored to $ to avoid corrupting paths with parentheses (e.g., test(1).spec.ts)
|
|
286
|
+
path="$(printf '%s' "$path" | sed 's/[[:space:]]*([^)]*)$//')"
|
|
287
|
+
# Trim whitespace
|
|
288
|
+
path="$(printf '%s' "$path" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
289
|
+
if [[ -n "$path" ]]; then
|
|
290
|
+
raw_files+=("$path")
|
|
291
|
+
fi
|
|
292
|
+
# Remove the matched backtick segment — use fixed pattern to avoid
|
|
293
|
+
# glob interpretation of path characters like [ ] * ?
|
|
294
|
+
line="${line#*\`}" # strip up to and including the opening backtick
|
|
295
|
+
line="${line#*\`}" # strip the content and closing backtick
|
|
296
|
+
done
|
|
297
|
+
done < <(grep -i '^File(s):' "$task_file_path" 2>/dev/null || true)
|
|
298
|
+
|
|
299
|
+
# If no File(s): lines found, fall back
|
|
300
|
+
if [[ ${#raw_files[@]} -eq 0 ]]; then
|
|
301
|
+
confidence="low"
|
|
302
|
+
fi
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
# Sanitize file paths, derive modules, deduplicate via jq (Bash 3.2 compatible)
|
|
306
|
+
local sanitized_paths=""
|
|
307
|
+
|
|
308
|
+
for raw_path in "${raw_files[@]}"; do
|
|
309
|
+
# OWASP A03: reject paths with shell injection patterns
|
|
310
|
+
if printf '%s' "$raw_path" | grep -qE '(\$\(|`|;|\||\&\&|\|\|)'; then
|
|
311
|
+
continue
|
|
312
|
+
fi
|
|
313
|
+
# Skip paths with only dots/slashes (directory traversal)
|
|
314
|
+
if [[ "$raw_path" =~ ^[./]+$ ]]; then
|
|
315
|
+
continue
|
|
316
|
+
fi
|
|
317
|
+
sanitized_paths="${sanitized_paths}${raw_path}"$'\n'
|
|
318
|
+
done
|
|
319
|
+
|
|
320
|
+
# Build deduplicated JSON arrays using jq (no associative arrays needed)
|
|
321
|
+
local files_json modules_json
|
|
322
|
+
if [[ -n "$sanitized_paths" ]]; then
|
|
323
|
+
files_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | jq -R . | jq -s -c 'unique')"
|
|
324
|
+
modules_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | while IFS= read -r p; do
|
|
325
|
+
local d
|
|
326
|
+
d="$(dirname "$p")"
|
|
327
|
+
if [[ "$d" == "." ]]; then printf '%s\n' "./"; else printf '%s\n' "${d}/"; fi
|
|
328
|
+
done | jq -R . | jq -s -c 'unique')"
|
|
329
|
+
else
|
|
330
|
+
files_json="[]"
|
|
331
|
+
modules_json="[]"
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
# Build and append the entry
|
|
335
|
+
local updated_at
|
|
336
|
+
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
337
|
+
|
|
338
|
+
local jsonl_path
|
|
339
|
+
jsonl_path="$(_file_index_path)"
|
|
340
|
+
mkdir -p "$(dirname "$jsonl_path")"
|
|
341
|
+
|
|
342
|
+
if [[ "$confidence" == "low" ]]; then
|
|
343
|
+
# Include confidence field for fallback entries
|
|
344
|
+
local json_line
|
|
345
|
+
json_line="$(jq -n -c \
|
|
346
|
+
--arg id "$issue_id" \
|
|
347
|
+
--arg dev "$developer" \
|
|
348
|
+
--argjson files "$files_json" \
|
|
349
|
+
--argjson modules "$modules_json" \
|
|
350
|
+
--arg ts "$updated_at" \
|
|
351
|
+
--arg conf "$confidence" \
|
|
352
|
+
'{
|
|
353
|
+
issue_id: $id,
|
|
354
|
+
developer: $dev,
|
|
355
|
+
files: $files,
|
|
356
|
+
modules: $modules,
|
|
357
|
+
updated_at: $ts,
|
|
358
|
+
tombstone: false,
|
|
359
|
+
confidence: $conf
|
|
360
|
+
}')"
|
|
361
|
+
printf '%s\n' "$json_line" >> "$jsonl_path"
|
|
362
|
+
else
|
|
363
|
+
# Normal entry via file_index_add (no confidence field needed)
|
|
364
|
+
file_index_add "$issue_id" "$developer" "$files_json" "$modules_json"
|
|
365
|
+
fi
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# ── Main dispatcher (when run as a script) ────────────────────────────────
|
|
369
|
+
|
|
370
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
371
|
+
if [[ $# -lt 1 ]]; then
|
|
372
|
+
cat >&2 <<'EOF'
|
|
373
|
+
Usage: file-index.sh <subcommand> [args...]
|
|
374
|
+
|
|
375
|
+
Subcommands:
|
|
376
|
+
add <issue_id> <developer> <files_json> <modules_json>
|
|
377
|
+
remove <issue_id>
|
|
378
|
+
read
|
|
379
|
+
get <issue_id>
|
|
380
|
+
update-from-tasks <issue_id> <task_file_path> [action]
|
|
381
|
+
EOF
|
|
382
|
+
exit 1
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
subcommand="$1"
|
|
386
|
+
shift
|
|
387
|
+
|
|
388
|
+
case "$subcommand" in
|
|
389
|
+
add) file_index_add "$@" ;;
|
|
390
|
+
remove) file_index_remove "$@" ;;
|
|
391
|
+
read) file_index_read ;;
|
|
392
|
+
get) file_index_get "$@" ;;
|
|
393
|
+
update-from-tasks) file_index_update_from_tasks "$@" ;;
|
|
394
|
+
*)
|
|
395
|
+
echo "Error: Unknown subcommand '${subcommand}'" >&2
|
|
396
|
+
exit 1
|
|
397
|
+
;;
|
|
398
|
+
esac
|
|
399
|
+
fi
|