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