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.
Files changed (209) hide show
  1. package/.claude/commands/dev.md +340 -314
  2. package/.claude/commands/plan.md +521 -478
  3. package/.claude/commands/premerge.md +176 -179
  4. package/.claude/commands/research.md +42 -42
  5. package/.claude/commands/review.md +442 -442
  6. package/.claude/commands/rollback.md +721 -721
  7. package/.claude/commands/ship.md +164 -134
  8. package/.claude/commands/sonarcloud.md +152 -152
  9. package/.claude/commands/status.md +48 -77
  10. package/.claude/commands/validate.md +282 -237
  11. package/.claude/commands/verify.md +221 -221
  12. package/.claude/rules/greptile-review-process.md +285 -285
  13. package/.claude/rules/workflow.md +105 -105
  14. package/.claude/scripts/greptile-resolve.sh +526 -526
  15. package/.claude/scripts/load-env.sh +32 -32
  16. package/.cline/workflows/dev.md +337 -311
  17. package/.cline/workflows/plan.md +518 -475
  18. package/.cline/workflows/premerge.md +173 -176
  19. package/.cline/workflows/research.md +39 -39
  20. package/.cline/workflows/review.md +439 -439
  21. package/.cline/workflows/rollback.md +718 -718
  22. package/.cline/workflows/ship.md +161 -131
  23. package/.cline/workflows/sonarcloud.md +146 -146
  24. package/.cline/workflows/status.md +45 -74
  25. package/.cline/workflows/validate.md +279 -234
  26. package/.cline/workflows/verify.md +218 -218
  27. package/.codex/config.toml +11 -11
  28. package/.codex/skills/dev/SKILL.md +340 -314
  29. package/.codex/skills/plan/SKILL.md +521 -478
  30. package/.codex/skills/premerge/SKILL.md +176 -179
  31. package/.codex/skills/research/SKILL.md +42 -42
  32. package/.codex/skills/review/SKILL.md +442 -442
  33. package/.codex/skills/rollback/SKILL.md +721 -721
  34. package/.codex/skills/ship/SKILL.md +164 -134
  35. package/.codex/skills/sonarcloud/SKILL.md +149 -149
  36. package/.codex/skills/status/SKILL.md +48 -77
  37. package/.codex/skills/validate/SKILL.md +282 -237
  38. package/.codex/skills/verify/SKILL.md +221 -221
  39. package/.cursor/commands/dev.md +337 -311
  40. package/.cursor/commands/plan.md +518 -475
  41. package/.cursor/commands/premerge.md +173 -176
  42. package/.cursor/commands/research.md +39 -39
  43. package/.cursor/commands/review.md +439 -439
  44. package/.cursor/commands/rollback.md +718 -718
  45. package/.cursor/commands/ship.md +161 -131
  46. package/.cursor/commands/sonarcloud.md +146 -146
  47. package/.cursor/commands/status.md +45 -74
  48. package/.cursor/commands/validate.md +279 -234
  49. package/.cursor/commands/verify.md +218 -218
  50. package/.cursor/rules/permissions-guidance.mdc +37 -37
  51. package/.forge/hooks/check-tdd.js +240 -240
  52. package/.github/PLUGIN_TEMPLATE.json +32 -32
  53. package/.github/prompts/dev.prompt.md +342 -316
  54. package/.github/prompts/plan.prompt.md +523 -480
  55. package/.github/prompts/premerge.prompt.md +178 -181
  56. package/.github/prompts/research.prompt.md +44 -44
  57. package/.github/prompts/review.prompt.md +444 -444
  58. package/.github/prompts/rollback.prompt.md +723 -723
  59. package/.github/prompts/ship.prompt.md +166 -136
  60. package/.github/prompts/sonarcloud.prompt.md +151 -151
  61. package/.github/prompts/status.prompt.md +50 -79
  62. package/.github/prompts/validate.prompt.md +284 -239
  63. package/.github/prompts/verify.prompt.md +223 -223
  64. package/.github/workflows/beads-to-github.yml +56 -0
  65. package/.github/workflows/github-to-beads.yml +97 -0
  66. package/.kilocode/workflows/dev.md +341 -315
  67. package/.kilocode/workflows/plan.md +522 -479
  68. package/.kilocode/workflows/premerge.md +177 -180
  69. package/.kilocode/workflows/research.md +43 -43
  70. package/.kilocode/workflows/review.md +443 -443
  71. package/.kilocode/workflows/rollback.md +722 -722
  72. package/.kilocode/workflows/ship.md +165 -135
  73. package/.kilocode/workflows/sonarcloud.md +150 -150
  74. package/.kilocode/workflows/status.md +49 -78
  75. package/.kilocode/workflows/validate.md +283 -238
  76. package/.kilocode/workflows/verify.md +222 -222
  77. package/.mcp.json.example +12 -12
  78. package/.opencode/commands/dev.md +340 -314
  79. package/.opencode/commands/plan.md +521 -478
  80. package/.opencode/commands/premerge.md +176 -179
  81. package/.opencode/commands/research.md +42 -42
  82. package/.opencode/commands/review.md +442 -442
  83. package/.opencode/commands/rollback.md +721 -721
  84. package/.opencode/commands/ship.md +164 -134
  85. package/.opencode/commands/sonarcloud.md +149 -149
  86. package/.opencode/commands/status.md +48 -77
  87. package/.opencode/commands/validate.md +282 -237
  88. package/.opencode/commands/verify.md +221 -221
  89. package/.roo/commands/dev.md +341 -315
  90. package/.roo/commands/plan.md +522 -479
  91. package/.roo/commands/premerge.md +177 -180
  92. package/.roo/commands/research.md +43 -43
  93. package/.roo/commands/review.md +443 -443
  94. package/.roo/commands/rollback.md +722 -722
  95. package/.roo/commands/ship.md +165 -135
  96. package/.roo/commands/sonarcloud.md +150 -150
  97. package/.roo/commands/status.md +49 -78
  98. package/.roo/commands/validate.md +283 -238
  99. package/.roo/commands/verify.md +222 -222
  100. package/AGENTS.md +175 -169
  101. package/CLAUDE.md +100 -99
  102. package/LICENSE +21 -21
  103. package/README.md +429 -414
  104. package/bin/forge-cmd.js +313 -313
  105. package/bin/{forge-validate.js → forge-preflight.js} +309 -303
  106. package/bin/forge.js +4596 -4232
  107. package/docs/AGENT_INSTALL_PROMPT.md +342 -342
  108. package/docs/BEADS_GITHUB_SYNC.md +251 -0
  109. package/docs/ENHANCED_ONBOARDING.md +602 -602
  110. package/docs/EXAMPLES.md +482 -482
  111. package/docs/GREPTILE_SETUP.md +400 -400
  112. package/docs/MANUAL_REVIEW_GUIDE.md +106 -106
  113. package/docs/ROADMAP.md +359 -359
  114. package/docs/SETUP.md +663 -632
  115. package/docs/TOOLCHAIN.md +630 -630
  116. package/docs/VALIDATION.md +363 -363
  117. package/install.sh +40 -1058
  118. package/lefthook.yml +39 -39
  119. package/lib/agents/README.md +198 -198
  120. package/lib/agents/claude.plugin.json +28 -28
  121. package/lib/agents/cline.plugin.json +22 -22
  122. package/lib/agents/codex.plugin.json +19 -19
  123. package/lib/agents/copilot.plugin.json +24 -24
  124. package/lib/agents/cursor.plugin.json +25 -25
  125. package/lib/agents/kilocode.plugin.json +22 -22
  126. package/lib/agents/opencode.plugin.json +20 -20
  127. package/lib/agents/roo.plugin.json +23 -23
  128. package/lib/agents-config.js +2112 -2112
  129. package/lib/beads-health-check.js +143 -0
  130. package/lib/beads-setup.js +341 -0
  131. package/lib/beads-sync-scaffold.js +260 -0
  132. package/lib/commands/dev.js +513 -513
  133. package/lib/commands/plan.js +692 -692
  134. package/lib/commands/recommend.js +119 -119
  135. package/lib/commands/ship.js +377 -377
  136. package/lib/commands/status.js +378 -378
  137. package/lib/commands/validate.js +602 -602
  138. package/lib/context-merge.js +359 -359
  139. package/lib/dep-guard/analyzer.js +294 -294
  140. package/lib/dep-guard/behavior-detector.js +98 -98
  141. package/lib/dep-guard/contract-detector.js +162 -162
  142. package/lib/dep-guard/import-detector.js +498 -498
  143. package/lib/dep-guard/path-utils.js +13 -13
  144. package/lib/dep-guard/rubric.js +120 -120
  145. package/lib/dep-guard/task-parser.js +318 -318
  146. package/lib/detect-agent.js +191 -0
  147. package/lib/detect-worktree.js +47 -0
  148. package/lib/file-hash.js +26 -0
  149. package/lib/husky-migration.js +450 -0
  150. package/lib/lefthook-check.js +65 -0
  151. package/lib/pat-setup.js +207 -0
  152. package/lib/plugin-catalog.js +350 -350
  153. package/lib/plugin-manager.js +166 -166
  154. package/lib/plugin-recommender.js +141 -141
  155. package/lib/project-discovery.js +491 -491
  156. package/lib/setup-action-log.js +139 -0
  157. package/lib/setup-summary-renderer.js +106 -0
  158. package/lib/setup-utils.js +96 -0
  159. package/lib/setup.js +192 -118
  160. package/lib/smart-merge.js +64 -0
  161. package/lib/symlink-utils.js +81 -0
  162. package/lib/workflow-profiles.js +197 -197
  163. package/package.json +131 -129
  164. package/scripts/beads-context.sh +291 -0
  165. package/scripts/beads-context.test.js +563 -0
  166. package/scripts/behavioral-judge.sh +378 -0
  167. package/scripts/benchmark.js +85 -0
  168. package/scripts/branch-protection.js +183 -0
  169. package/scripts/check-agents.js +172 -0
  170. package/scripts/commitlint.js +42 -0
  171. package/scripts/conflict-detect.sh +323 -0
  172. package/scripts/dep-guard-analyze.js +71 -0
  173. package/scripts/dep-guard.sh +811 -0
  174. package/scripts/eval_win.py +249 -0
  175. package/scripts/file-index.sh +399 -0
  176. package/scripts/github-beads-sync/comment.mjs +64 -0
  177. package/scripts/github-beads-sync/config.mjs +148 -0
  178. package/scripts/github-beads-sync/github-api.mjs +131 -0
  179. package/scripts/github-beads-sync/index.mjs +332 -0
  180. package/scripts/github-beads-sync/label-mapper.mjs +54 -0
  181. package/scripts/github-beads-sync/mapping.mjs +78 -0
  182. package/scripts/github-beads-sync/reverse-sync-cli.mjs +31 -0
  183. package/scripts/github-beads-sync/reverse-sync.mjs +138 -0
  184. package/scripts/github-beads-sync/run-bd.mjs +159 -0
  185. package/scripts/github-beads-sync/sanitize.mjs +121 -0
  186. package/scripts/github-beads-sync.config.json +26 -0
  187. package/scripts/improve-command.js +375 -0
  188. package/scripts/lib/eval-runner.js +229 -0
  189. package/scripts/lib/eval-schema.js +135 -0
  190. package/scripts/lib/eval-storage.js +78 -0
  191. package/scripts/lib/grading.js +203 -0
  192. package/scripts/lib/transcript-parser.js +63 -0
  193. package/scripts/lint.js +47 -0
  194. package/scripts/migrate-to-bun-test.js +412 -0
  195. package/scripts/run-command-eval.js +236 -0
  196. package/scripts/smart-status.sh +782 -0
  197. package/scripts/sync-commands.js +571 -0
  198. package/scripts/sync-utils.sh +460 -0
  199. package/scripts/test-dashboard.js +123 -0
  200. package/scripts/test.js +44 -0
  201. package/scripts/validate.sh +94 -0
  202. package/skills/parallel-deep-research/SKILL.md +108 -108
  203. package/skills/parallel-deep-research/evals/README.md +27 -27
  204. package/skills/parallel-deep-research/evals/evals.json +62 -62
  205. package/skills/sonarcloud-analysis/SKILL.md +171 -171
  206. package/skills/sonarcloud-analysis/evals/README.md +27 -27
  207. package/skills/sonarcloud-analysis/evals/evals.json +50 -50
  208. package/skills/sonarcloud-analysis/references/api-reference.md +466 -466
  209. 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