forge-workflow 0.0.5 → 0.0.6

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 (129) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.github/prompts/dev.prompt.md +6 -1
  36. package/.github/prompts/plan.prompt.md +59 -14
  37. package/.github/prompts/premerge.prompt.md +10 -0
  38. package/.github/prompts/review.prompt.md +7 -1
  39. package/.github/prompts/ship.prompt.md +95 -47
  40. package/.github/prompts/status.prompt.md +42 -0
  41. package/.github/prompts/validate.prompt.md +7 -1
  42. package/.github/prompts/verify.prompt.md +52 -4
  43. package/.kilocode/workflows/dev.md +6 -1
  44. package/.kilocode/workflows/plan.md +59 -14
  45. package/.kilocode/workflows/premerge.md +10 -0
  46. package/.kilocode/workflows/review.md +7 -1
  47. package/.kilocode/workflows/ship.md +95 -47
  48. package/.kilocode/workflows/status.md +42 -0
  49. package/.kilocode/workflows/validate.md +7 -1
  50. package/.kilocode/workflows/verify.md +52 -4
  51. package/.opencode/commands/dev.md +6 -1
  52. package/.opencode/commands/plan.md +59 -14
  53. package/.opencode/commands/premerge.md +10 -0
  54. package/.opencode/commands/review.md +7 -1
  55. package/.opencode/commands/ship.md +95 -47
  56. package/.opencode/commands/status.md +42 -0
  57. package/.opencode/commands/validate.md +7 -1
  58. package/.opencode/commands/verify.md +52 -4
  59. package/.roo/commands/dev.md +6 -1
  60. package/.roo/commands/plan.md +59 -14
  61. package/.roo/commands/premerge.md +10 -0
  62. package/.roo/commands/review.md +7 -1
  63. package/.roo/commands/ship.md +95 -47
  64. package/.roo/commands/status.md +42 -0
  65. package/.roo/commands/validate.md +7 -1
  66. package/.roo/commands/verify.md +52 -4
  67. package/AGENTS.md +97 -0
  68. package/CLAUDE.md +10 -0
  69. package/README.md +2 -2
  70. package/bin/forge-cmd.js +5 -1
  71. package/bin/forge-preflight.js +15 -2
  72. package/bin/forge.js +178 -9
  73. package/docs/ENHANCED_ONBOARDING.md +96 -86
  74. package/docs/ROADMAP.md +2 -2
  75. package/docs/TOOLCHAIN.md +23 -0
  76. package/docs/VALIDATION.md +1 -1
  77. package/lefthook.yml +11 -0
  78. package/lib/agents-config.js +2 -2
  79. package/lib/commands/_registry.js +134 -0
  80. package/lib/commands/clean.js +181 -0
  81. package/lib/commands/dev.js +58 -0
  82. package/lib/commands/push.js +196 -0
  83. package/lib/commands/recommend.js +1 -1
  84. package/lib/commands/sync.js +55 -0
  85. package/lib/commands/team.js +37 -0
  86. package/lib/commands/test.js +207 -0
  87. package/lib/commands/worktree.js +310 -0
  88. package/lib/docs-command.js +51 -0
  89. package/lib/docs-copy.js +50 -0
  90. package/lib/freshness-token.js +148 -0
  91. package/lib/greptile-match.js +80 -0
  92. package/lib/reset.js +309 -0
  93. package/lib/task-ownership.js +117 -0
  94. package/package.json +2 -2
  95. package/scripts/beads-context.sh +157 -22
  96. package/scripts/beads-context.test.js +5 -1
  97. package/scripts/check-forge-token.js +98 -0
  98. package/scripts/conflict-detect.sh +2 -2
  99. package/scripts/dep-guard.sh +6 -28
  100. package/scripts/file-index.sh +117 -23
  101. package/scripts/forge-team/index.sh +86 -0
  102. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  103. package/scripts/forge-team/lib/claim.sh +256 -0
  104. package/scripts/forge-team/lib/dashboard.sh +341 -0
  105. package/scripts/forge-team/lib/epic.sh +332 -0
  106. package/scripts/forge-team/lib/hooks.sh +253 -0
  107. package/scripts/forge-team/lib/identity.sh +235 -0
  108. package/scripts/forge-team/lib/sync-github.sh +317 -0
  109. package/scripts/forge-team/lib/verify.sh +284 -0
  110. package/scripts/forge-team/lib/workload.sh +296 -0
  111. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  112. package/scripts/forge-team/tests/claim.test.sh +179 -0
  113. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  114. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  115. package/scripts/forge-team/tests/epic.test.sh +176 -0
  116. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  117. package/scripts/forge-team/tests/identity.test.sh +176 -0
  118. package/scripts/forge-team/tests/integration.test.sh +371 -0
  119. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  120. package/scripts/forge-team/tests/verify.test.sh +314 -0
  121. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  122. package/scripts/forge-team/tests/workload.test.sh +209 -0
  123. package/scripts/lib/eval-runner.js +39 -0
  124. package/scripts/lib/jsonl-lock.sh +48 -0
  125. package/scripts/lib/sanitize.sh +116 -0
  126. package/scripts/pr-coordinator.sh +706 -0
  127. package/scripts/smart-status.sh +37 -10
  128. package/scripts/sync-utils.sh +24 -29
  129. package/scripts/test.js +3 -1
@@ -7,13 +7,18 @@
7
7
  # set-acceptance <issue-id> "<criteria-text>"
8
8
  # update-progress <issue-id> <task-num> <total> "<title>" <commit-sha> <test-count> <gate-count>
9
9
  # parse-progress <issue-id>
10
- # stage-transition <issue-id> <completed-stage> <next-stage>
10
+ # stage-transition <issue-id> <completed-stage> <next-stage> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."]
11
+ # validate <issue-id>
11
12
  #
12
13
  # Cross-platform: works on Windows (Git Bash), macOS, and Linux.
13
14
  # OWASP A03: All variables properly quoted to prevent shell injection.
14
15
 
15
16
  set -euo pipefail
16
17
 
18
+ # Source shared sanitize library
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ source "$SCRIPT_DIR/lib/sanitize.sh"
21
+
17
22
  # ── Helpers ──────────────────────────────────────────────────────────────
18
23
 
19
24
  usage() {
@@ -25,7 +30,8 @@ Subcommands:
25
30
  set-acceptance <issue-id> "<criteria-text>"
26
31
  update-progress <issue-id> <task-num> <total> "<title>" <commit-sha> <test-count> <gate-count>
27
32
  parse-progress <issue-id>
28
- stage-transition <issue-id> <completed-stage> <next-stage>
33
+ stage-transition <issue-id> <completed-stage> <next-stage> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."]
34
+ validate <issue-id>
29
35
  EOF
30
36
  exit 1
31
37
  }
@@ -35,24 +41,6 @@ die() {
35
41
  exit 1
36
42
  }
37
43
 
38
- # Sanitize a string: strip shell-injection patterns (OWASP A03)
39
- # Removes: double quotes, $(...), backticks, semicolons, and newlines
40
- sanitize() {
41
- local val="$1"
42
- # Remove double quotes
43
- val="${val//\"/}"
44
- # Remove $(...) command substitution patterns (loop handles nested)
45
- # Use newline-separated commands for BSD sed compatibility (macOS)
46
- val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
47
- # Remove backtick command substitution
48
- val="${val//\`/}"
49
- # Remove semicolons (command chaining)
50
- val="${val//;/}"
51
- # Replace newlines with spaces
52
- val="$(printf '%s' "$val" | tr '\n' ' ')"
53
- printf '%s' "$val"
54
- }
55
-
56
44
  # Run bd update and check for errors in both exit code and output.
57
45
  # bd update exits 0 even for non-existent issues, so we check stdout too.
58
46
  bd_update() {
@@ -250,7 +238,7 @@ cmd_parse_progress() {
250
238
 
251
239
  cmd_stage_transition() {
252
240
  if [[ $# -lt 3 ]]; then
253
- echo "Usage: beads-context.sh stage-transition <issue-id> <completed-stage> <next-stage>" >&2
241
+ echo "Usage: beads-context.sh stage-transition <issue-id> <completed-stage> <next-stage> [--summary \"...\"] [--decisions \"...\"] [--artifacts \"...\"] [--next \"...\"]" >&2
254
242
  exit 1
255
243
  fi
256
244
 
@@ -259,9 +247,56 @@ cmd_stage_transition() {
259
247
  completed="$(sanitize "$2")"
260
248
  local next
261
249
  next="$(sanitize "$3")"
262
-
250
+ shift 3
251
+
252
+ # Parse optional flags
253
+ local flag_summary="" flag_decisions="" flag_artifacts="" flag_next=""
254
+ while [[ $# -gt 0 ]]; do
255
+ case "$1" in
256
+ --summary)
257
+ shift
258
+ flag_summary="$(sanitize "${1:-}")"
259
+ ;;
260
+ --decisions)
261
+ shift
262
+ flag_decisions="$(sanitize "${1:-}")"
263
+ ;;
264
+ --artifacts)
265
+ shift
266
+ flag_artifacts="$(sanitize "${1:-}")"
267
+ ;;
268
+ --next)
269
+ shift
270
+ flag_next="$(sanitize "${1:-}")"
271
+ ;;
272
+ *)
273
+ # Ignore unknown flags for forward compatibility
274
+ ;;
275
+ esac
276
+ shift
277
+ done
278
+
279
+ # Build comment: header line always present
263
280
  local comment="Stage: ${completed} complete → ready for ${next}"
264
281
 
282
+ # Append structured fields only if provided
283
+ if [[ -n "$flag_summary" ]]; then
284
+ comment="${comment}
285
+ Summary: ${flag_summary}"
286
+ fi
287
+ if [[ -n "$flag_decisions" ]]; then
288
+ comment="${comment}
289
+ Decisions: ${flag_decisions}"
290
+ fi
291
+ if [[ -n "$flag_artifacts" ]]; then
292
+ comment="${comment}
293
+ Artifacts: ${flag_artifacts}"
294
+ fi
295
+ if [[ -n "$flag_next" ]]; then
296
+ comment="${comment}
297
+ Next: ${flag_next}"
298
+ fi
299
+
265
300
  if ! bd_comment "$issue_id" "$comment" > /dev/null; then
266
301
  die "Failed to record stage transition on issue ${issue_id}"
267
302
  fi
@@ -269,6 +304,105 @@ cmd_stage_transition() {
269
304
  echo "Stage transition recorded on ${issue_id}: ${completed} → ${next}"
270
305
  }
271
306
 
307
+ cmd_validate() {
308
+ if [[ $# -lt 1 ]]; then
309
+ echo "Usage: beads-context.sh validate <issue-id>" >&2
310
+ exit 1
311
+ fi
312
+
313
+ local issue_id="$1"
314
+ local warnings=0
315
+
316
+ # Get issue JSON
317
+ local json
318
+ json="$(bd show "$issue_id" --json 2>&1)" || {
319
+ echo "Error: Failed to retrieve issue ${issue_id}" >&2
320
+ exit 1
321
+ }
322
+
323
+ # Check for bd error patterns
324
+ if printf '%s' "$json" | grep -Eqi '^Error|Error resolving|Error fetching'; then
325
+ echo "Error: Issue not found: ${issue_id}" >&2
326
+ exit 1
327
+ fi
328
+
329
+ if [[ -z "$json" || "$json" == "[]" || "$json" == "null" ]]; then
330
+ echo "Error: Issue not found: ${issue_id}" >&2
331
+ exit 1
332
+ fi
333
+
334
+ # Extract fields — prefer jq, fall back to grep/sed
335
+ local description="" design=""
336
+ if command -v jq &>/dev/null; then
337
+ description="$(printf '%s' "$json" | jq -r 'if type == "array" then .[0].description else .description end // empty' 2>/dev/null || true)"
338
+ design="$(printf '%s' "$json" | jq -r 'if type == "array" then .[0].design else .design end // empty' 2>/dev/null || true)"
339
+ else
340
+ description="$(printf '%s' "$json" | grep -o '"description": *"[^"]*"' | head -1 | sed 's/^"description": *"//;s/"$//' || true)"
341
+ design="$(printf '%s' "$json" | grep -o '"design": *"[^"]*"' | head -1 | sed 's/^"design": *"//;s/"$//' || true)"
342
+ fi
343
+
344
+ # Check 1: Issue has description
345
+ if [[ -z "$description" ]]; then
346
+ echo "Warning: Issue ${issue_id} has no description"
347
+ warnings=$((warnings + 1))
348
+ fi
349
+
350
+ # Get comments to check for stage transitions
351
+ local comments
352
+ comments="$(bd comments list "$issue_id" 2>/dev/null || true)"
353
+
354
+ # Check 2: At least one stage transition exists
355
+ local has_transition=false
356
+ if echo "$comments" | grep -q 'Stage:.*complete'; then
357
+ has_transition=true
358
+ fi
359
+
360
+ if [[ "$has_transition" == "false" ]]; then
361
+ echo "Warning: No stage transition found on issue ${issue_id}"
362
+ warnings=$((warnings + 1))
363
+ fi
364
+
365
+ # Check 3: Most recent transition has summary
366
+ # Isolate the last transition block to avoid false-positives from earlier summaries
367
+ if [[ "$has_transition" == "true" ]]; then
368
+ local has_summary=false
369
+ local last_block
370
+ last_block="$(echo "$comments" | awk '/Stage:.*complete/{found=1; block=""} found{block=block"\n"$0} END{print block}')"
371
+ if echo "$last_block" | grep -q 'Summary:'; then
372
+ has_summary=true
373
+ fi
374
+ if [[ "$has_summary" == "false" ]]; then
375
+ echo "Warning: Most recent stage transition has no summary on issue ${issue_id}"
376
+ warnings=$((warnings + 1))
377
+ fi
378
+ fi
379
+
380
+ # Check 4: Design metadata is set if past plan stage
381
+ # "Past plan" means there's a transition FROM plan or any later stage
382
+ local past_plan=false
383
+ if echo "$comments" | grep -q 'Stage: plan complete'; then
384
+ past_plan=true
385
+ fi
386
+ if echo "$comments" | grep -Eq 'Stage: (dev|validate|ship|review|premerge) complete'; then
387
+ past_plan=true
388
+ fi
389
+
390
+ if [[ "$past_plan" == "true" && -z "$design" ]]; then
391
+ echo "Warning: Design metadata not set on issue ${issue_id} (past plan stage)"
392
+ warnings=$((warnings + 1))
393
+ fi
394
+
395
+ # Summary
396
+ if [[ $warnings -eq 0 ]]; then
397
+ echo "All context fields present on issue ${issue_id}"
398
+ else
399
+ echo "${warnings} warning(s) found on issue ${issue_id}"
400
+ fi
401
+
402
+ # Always exit 0 — advisory only
403
+ exit 0
404
+ }
405
+
272
406
  # ── Main dispatcher ──────────────────────────────────────────────────────
273
407
 
274
408
  if [[ $# -lt 1 ]]; then
@@ -284,6 +418,7 @@ case "$subcommand" in
284
418
  update-progress) cmd_update_progress "$@" ;;
285
419
  parse-progress) cmd_parse_progress "$@" ;;
286
420
  stage-transition) cmd_stage_transition "$@" ;;
421
+ validate) cmd_validate "$@" ;;
287
422
  *)
288
423
  echo "Error: Unknown subcommand '${subcommand}'" >&2
289
424
  usage
@@ -8,10 +8,14 @@ const { resolveBashCommand } = require('../test/helpers/bash.js');
8
8
  const SCRIPT_PATH = path.join(__dirname, 'beads-context.sh');
9
9
  const WORKTREE_ROOT = path.resolve(__dirname, '..');
10
10
 
11
- // Check if bd CLI is available (not installed in CI)
11
+ // Check if bd CLI is available AND Dolt database is reachable (not just binary exists).
12
+ // Prevents tests from hanging when Dolt is dead. BD_TIMEOUT env var overrides default.
12
13
  function isBdAvailable() {
13
14
  try {
14
15
  execFileSync('bd', ['--version'], { stdio: 'ignore' });
16
+ // Also verify Dolt database is reachable (not just binary exists)
17
+ const timeout = parseInt(process.env.BD_TIMEOUT || '3000', 10);
18
+ execFileSync('bd', ['list', '--limit=1'], { stdio: 'ignore', timeout });
15
19
  return true;
16
20
  } catch {
17
21
  return false;
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+ const path = require('node:path');
6
+
7
+ const TOKEN_FILENAME = '.forge-push-token';
8
+ const MAX_AGE_MS = 30000; // 30 seconds
9
+
10
+ /**
11
+ * Get the absolute path to the token file.
12
+ *
13
+ * @param {string} projectRoot - Absolute path to project root
14
+ * @returns {string} Token file path
15
+ */
16
+ function tokenPath(projectRoot) {
17
+ return path.join(projectRoot, TOKEN_FILENAME);
18
+ }
19
+
20
+ /**
21
+ * Write a one-time nonce token to disk.
22
+ * Called by `forge push` right before `git push` so lefthook hooks
23
+ * can detect that checks already passed and skip re-running them.
24
+ *
25
+ * @param {string} projectRoot - Absolute path to project root
26
+ * @returns {{nonce: string, timestamp: number}} The written token data
27
+ */
28
+ function write(projectRoot) {
29
+ const data = {
30
+ nonce: crypto.randomUUID(),
31
+ timestamp: Date.now(),
32
+ };
33
+ fs.writeFileSync(tokenPath(projectRoot), JSON.stringify(data), 'utf-8');
34
+ return data;
35
+ }
36
+
37
+ /**
38
+ * Check if a valid (fresh, well-formed) forge push token exists.
39
+ * Does NOT delete the token — use `consume()` for one-time validation.
40
+ *
41
+ * @param {string} projectRoot - Absolute path to project root
42
+ * @returns {boolean} True if a valid, fresh token exists
43
+ */
44
+ function isValid(projectRoot) {
45
+ try {
46
+ const raw = fs.readFileSync(tokenPath(projectRoot), 'utf-8');
47
+ const content = JSON.parse(raw);
48
+ if (!content.nonce || typeof content.timestamp !== 'number') {
49
+ return false;
50
+ }
51
+ const age = Date.now() - content.timestamp;
52
+ return age < MAX_AGE_MS;
53
+ } catch (_err) {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validate and delete the token in one atomic operation (one-time use).
60
+ * Always attempts to delete the file, even if the token is stale or invalid.
61
+ *
62
+ * @param {string} projectRoot - Absolute path to project root
63
+ * @returns {boolean} True if the token was valid before deletion
64
+ */
65
+ function consume(projectRoot) {
66
+ const tp = tokenPath(projectRoot);
67
+ try {
68
+ const raw = fs.readFileSync(tp, 'utf-8');
69
+ // Always delete after reading — whether valid or not
70
+ try { fs.unlinkSync(tp); } catch (_e) { /* already gone */ }
71
+
72
+ const content = JSON.parse(raw);
73
+ if (!content.nonce || typeof content.timestamp !== 'number') {
74
+ return false;
75
+ }
76
+ const age = Date.now() - content.timestamp;
77
+ return age < MAX_AGE_MS;
78
+ } catch (_err) {
79
+ // File doesn't exist or isn't readable — try cleanup anyway
80
+ try { fs.unlinkSync(tp); } catch (_e) { /* nothing to clean */ }
81
+ return false;
82
+ }
83
+ }
84
+
85
+ module.exports = { write, isValid, consume };
86
+
87
+ // When run directly as a script (e.g., from lefthook skip check):
88
+ // Exit 0 = token valid (skip hooks), Exit 1 = run hooks normally.
89
+ // Uses isValid() (non-destructive) so multiple lefthook commands can
90
+ // each check the same token. The token expires after 30s automatically.
91
+ if (require.main === module) {
92
+ const projectRoot = process.cwd();
93
+ if (isValid(projectRoot)) {
94
+ console.log('forge push token valid — skipping pre-push hooks');
95
+ process.exit(0);
96
+ }
97
+ process.exit(1);
98
+ }
@@ -19,12 +19,12 @@ set -euo pipefail
19
19
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
20
 
21
21
  # Source file-index helpers (file_index_read, file_index_get)
22
- source "$SCRIPT_DIR/file-index.sh"
22
+ source "$SCRIPT_DIR/file-index.sh" || { echo "FATAL: failed to source file-index.sh" >&2; exit 2; }
23
23
 
24
24
  # Source sync-utils (get_session_identity, sanitize_config_value)
25
25
  # Only source if available (tests may not need all utils)
26
26
  if [[ -f "$SCRIPT_DIR/sync-utils.sh" ]]; then
27
- source "$SCRIPT_DIR/sync-utils.sh"
27
+ source "$SCRIPT_DIR/sync-utils.sh" || { echo "FATAL: failed to source sync-utils.sh" >&2; exit 2; }
28
28
  fi
29
29
 
30
30
  # ── Input Validation ─────────────────────────────────────────────────────
@@ -12,6 +12,10 @@
12
12
 
13
13
  set -euo pipefail
14
14
 
15
+ # Source shared sanitize library
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ source "$SCRIPT_DIR/lib/sanitize.sh"
18
+
15
19
  # ── Helpers ──────────────────────────────────────────────────────────────
16
20
 
17
21
  usage() {
@@ -64,24 +68,6 @@ rollback_and_die() {
64
68
  die "$message"
65
69
  }
66
70
 
67
- # Sanitize a string: strip shell-injection patterns (OWASP A03)
68
- # Removes: double quotes, $(...), backticks, semicolons, and newlines
69
- sanitize() {
70
- local val="$1"
71
- # Remove double quotes
72
- val="${val//\"/}"
73
- # Remove $(...) command substitution patterns (loop handles nested)
74
- # Use newline-separated commands for BSD sed compatibility (macOS)
75
- val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
76
- # Remove backtick command substitution
77
- val="${val//\`/}"
78
- # Remove semicolons (command chaining)
79
- val="${val//;/}"
80
- # Replace newlines with spaces
81
- val="$(printf '%s' "$val" | tr '\n' ' ')"
82
- printf '%s' "$val"
83
- }
84
-
85
71
  # Run bd update and check for errors in both exit code and output.
86
72
  # bd update exits 0 even for non-existent issues, so we check stdout too.
87
73
  bd_update() {
@@ -628,17 +614,9 @@ cmd_apply_decision() {
628
614
  die "Failed to add dependency ${dependent_issue} -> ${depends_on_issue}"
629
615
  }
630
616
 
631
- local cycles_output
632
- cycles_output="$(${BD_CMD:-bd} dep cycles 2>&1)" || {
633
- rollback_dependency "$dependent_issue" "$depends_on_issue"
634
- echo "$cycles_output" >&2
635
- die "Failed to validate dependency cycles"
636
- }
637
-
638
- if printf '%s' "$cycles_output" | grep -Eqi 'cycle' \
639
- && ! cycles_output_is_safe "$cycles_output"; then
617
+ # Check for cycles — use exit code as primary signal
618
+ if ! ${BD_CMD:-bd} dep cycles &>/dev/null; then
640
619
  rollback_dependency "$dependent_issue" "$depends_on_issue"
641
- echo "$cycles_output" >&2
642
620
  die "Cycle detected for ${dependent_issue} -> ${depends_on_issue}"
643
621
  fi
644
622
 
@@ -19,6 +19,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
19
19
  set -euo pipefail
20
20
  fi
21
21
 
22
+ # Source shared libraries
23
+ _FI_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ source "$_FI_SCRIPT_DIR/lib/sanitize.sh"
25
+ if [[ -f "$_FI_SCRIPT_DIR/lib/jsonl-lock.sh" ]]; then
26
+ source "$_FI_SCRIPT_DIR/lib/jsonl-lock.sh"
27
+ fi
28
+
22
29
  # ── Helpers ──────────────────────────────────────────────────────────────
23
30
 
24
31
  # Resolve the JSONL file path.
@@ -28,24 +35,6 @@ _file_index_path() {
28
35
  printf '%s' "$root/.beads/file-index.jsonl"
29
36
  }
30
37
 
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
38
  # Validate issue_id: alphanumeric + hyphens only
50
39
  _validate_issue_id() {
51
40
  local id="$1"
@@ -127,7 +116,11 @@ file_index_add() {
127
116
  # Ensure parent directory exists
128
117
  mkdir -p "$(dirname "$jsonl_path")"
129
118
 
130
- printf '%s\n' "$json_line" >> "$jsonl_path"
119
+ if command -v atomic_jsonl_append &>/dev/null; then
120
+ atomic_jsonl_append "$jsonl_path" "$json_line"
121
+ else
122
+ printf '%s\n' "$json_line" >> "$jsonl_path"
123
+ fi
131
124
  }
132
125
 
133
126
  # file_index_remove <issue_id>
@@ -163,7 +156,11 @@ file_index_remove() {
163
156
  local jsonl_path
164
157
  jsonl_path="$(_file_index_path)"
165
158
  mkdir -p "$(dirname "$jsonl_path")"
166
- printf '%s\n' "$json_line" >> "$jsonl_path"
159
+ if command -v atomic_jsonl_append &>/dev/null; then
160
+ atomic_jsonl_append "$jsonl_path" "$json_line"
161
+ else
162
+ printf '%s\n' "$json_line" >> "$jsonl_path"
163
+ fi
167
164
  }
168
165
 
169
166
  # file_index_read
@@ -320,12 +317,28 @@ file_index_update_from_tasks() {
320
317
  # Build deduplicated JSON arrays using jq (no associative arrays needed)
321
318
  local files_json modules_json
322
319
  if [[ -n "$sanitized_paths" ]]; then
323
- files_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | jq -R . | jq -s -c 'unique')"
320
+ # Save and restore pipefail state to avoid side effects
321
+ local _prev_pipefail
322
+ _prev_pipefail="$(set +o | grep pipefail)" || true
323
+ set -o pipefail
324
+
325
+ files_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | jq -R . | jq -s -c 'unique')" || {
326
+ eval "$_prev_pipefail"
327
+ echo "Error: failed to build files JSON array" >&2
328
+ return 1
329
+ }
324
330
  modules_json="$(printf '%s' "$sanitized_paths" | grep -v '^$' | while IFS= read -r p; do
325
331
  local d
326
332
  d="$(dirname "$p")"
327
333
  if [[ "$d" == "." ]]; then printf '%s\n' "./"; else printf '%s\n' "${d}/"; fi
328
- done | jq -R . | jq -s -c 'unique')"
334
+ done | jq -R . | jq -s -c 'unique')" || {
335
+ eval "$_prev_pipefail"
336
+ echo "Error: failed to build modules JSON array" >&2
337
+ return 1
338
+ }
339
+
340
+ # Restore pipefail state
341
+ eval "$_prev_pipefail"
329
342
  else
330
343
  files_json="[]"
331
344
  modules_json="[]"
@@ -358,13 +371,92 @@ file_index_update_from_tasks() {
358
371
  tombstone: false,
359
372
  confidence: $conf
360
373
  }')"
361
- printf '%s\n' "$json_line" >> "$jsonl_path"
374
+ if command -v atomic_jsonl_append &>/dev/null; then
375
+ atomic_jsonl_append "$jsonl_path" "$json_line"
376
+ else
377
+ printf '%s\n' "$json_line" >> "$jsonl_path"
378
+ fi
362
379
  else
363
380
  # Normal entry via file_index_add (no confidence field needed)
364
381
  file_index_add "$issue_id" "$developer" "$files_json" "$modules_json"
365
382
  fi
366
383
  }
367
384
 
385
+ # file_index_auto_update <issue_id> [--from-git]
386
+ # Update the file index with the current set of changed files.
387
+ # Files come from: --from-git flag (git diff HEAD~1) or stdin.
388
+ file_index_auto_update() {
389
+ local raw_issue_id="$1"
390
+ shift
391
+ local from_git=0
392
+
393
+ # Parse flags
394
+ while [[ $# -gt 0 ]]; do
395
+ case "$1" in
396
+ --from-git) from_git=1; shift ;;
397
+ *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
398
+ esac
399
+ done
400
+
401
+ # Sanitize and validate issue_id
402
+ local issue_id
403
+ issue_id="$(sanitize "$raw_issue_id")"
404
+ issue_id="$(printf '%s' "$issue_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
405
+ _validate_issue_id "$issue_id" || return 1
406
+
407
+ # Get file list
408
+ local -a files=()
409
+ if [[ "$from_git" -eq 1 ]]; then
410
+ while IFS= read -r f; do
411
+ [[ -n "$f" ]] && files+=("$f")
412
+ done < <(git diff --name-only HEAD~1 2>/dev/null || true)
413
+ else
414
+ # Read from stdin (non-blocking check)
415
+ if [[ ! -t 0 ]]; then
416
+ while IFS= read -r f; do
417
+ [[ -n "$f" ]] && files+=("$f")
418
+ done
419
+ else
420
+ echo "Error: no file list provided (use --from-git or pipe files via stdin)" >&2
421
+ return 1
422
+ fi
423
+ fi
424
+
425
+ # Empty file list = nothing to do
426
+ if [[ ${#files[@]} -eq 0 ]]; then
427
+ echo "No files changed"
428
+ return 0
429
+ fi
430
+
431
+ # Get session identity
432
+ local developer
433
+ if ! command -v get_session_identity &>/dev/null; then
434
+ local script_dir
435
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
436
+ if [[ -f "$script_dir/sync-utils.sh" ]]; then
437
+ source "$script_dir/sync-utils.sh"
438
+ fi
439
+ fi
440
+ developer="$(get_session_identity)" || {
441
+ echo "Error: could not determine session identity" >&2
442
+ return 1
443
+ }
444
+
445
+ # Build JSON arrays using jq
446
+ local files_json modules_json
447
+ files_json="$(printf '%s\n' "${files[@]}" | jq -R . | jq -s -c 'unique')"
448
+ modules_json="$(printf '%s\n' "${files[@]}" | while IFS= read -r p; do
449
+ local d
450
+ d="$(dirname "$p")"
451
+ if [[ "$d" == "." ]]; then printf '%s\n' "./"; else printf '%s\n' "${d}/"; fi
452
+ done | jq -R . | jq -s -c 'unique')"
453
+
454
+ # Append via file_index_add (which uses atomic locking)
455
+ file_index_add "$issue_id" "$developer" "$files_json" "$modules_json"
456
+
457
+ echo "File index updated for $issue_id (${#files[@]} files)"
458
+ }
459
+
368
460
  # ── Main dispatcher (when run as a script) ────────────────────────────────
369
461
 
370
462
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
@@ -378,6 +470,7 @@ Subcommands:
378
470
  read
379
471
  get <issue_id>
380
472
  update-from-tasks <issue_id> <task_file_path> [action]
473
+ auto-update <issue_id> [--from-git]
381
474
  EOF
382
475
  exit 1
383
476
  fi
@@ -391,6 +484,7 @@ EOF
391
484
  read) file_index_read ;;
392
485
  get) file_index_get "$@" ;;
393
486
  update-from-tasks) file_index_update_from_tasks "$@" ;;
487
+ auto-update) file_index_auto_update "$@" ;;
394
488
  *)
395
489
  echo "Error: Unknown subcommand '${subcommand}'" >&2
396
490
  exit 1