forge-workflow 0.0.5 → 0.0.7

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 (164) 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/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. package/scripts/test.js +18 -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 "..."] [--workflow-state "{...}"]
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 "..."] [--workflow-state "{...}"]
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 \"...\"] [--workflow-state \"{...}\"]" >&2
254
242
  exit 1
255
243
  fi
256
244
 
@@ -259,9 +247,64 @@ 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="" flag_workflow_state=""
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
+ --workflow-state)
273
+ shift
274
+ flag_workflow_state="$(sanitize_config_value "${1:-}")"
275
+ ;;
276
+ *)
277
+ # Ignore unknown flags for forward compatibility
278
+ ;;
279
+ esac
280
+ shift
281
+ done
282
+
283
+ # Build comment: header line always present
263
284
  local comment="Stage: ${completed} complete → ready for ${next}"
264
285
 
286
+ # Append structured fields only if provided
287
+ if [[ -n "$flag_summary" ]]; then
288
+ comment="${comment}
289
+ Summary: ${flag_summary}"
290
+ fi
291
+ if [[ -n "$flag_decisions" ]]; then
292
+ comment="${comment}
293
+ Decisions: ${flag_decisions}"
294
+ fi
295
+ if [[ -n "$flag_artifacts" ]]; then
296
+ comment="${comment}
297
+ Artifacts: ${flag_artifacts}"
298
+ fi
299
+ if [[ -n "$flag_next" ]]; then
300
+ comment="${comment}
301
+ Next: ${flag_next}"
302
+ fi
303
+ if [[ -n "$flag_workflow_state" ]]; then
304
+ comment="${comment}
305
+ WorkflowState: ${flag_workflow_state}"
306
+ fi
307
+
265
308
  if ! bd_comment "$issue_id" "$comment" > /dev/null; then
266
309
  die "Failed to record stage transition on issue ${issue_id}"
267
310
  fi
@@ -269,6 +312,105 @@ cmd_stage_transition() {
269
312
  echo "Stage transition recorded on ${issue_id}: ${completed} → ${next}"
270
313
  }
271
314
 
315
+ cmd_validate() {
316
+ if [[ $# -lt 1 ]]; then
317
+ echo "Usage: beads-context.sh validate <issue-id>" >&2
318
+ exit 1
319
+ fi
320
+
321
+ local issue_id="$1"
322
+ local warnings=0
323
+
324
+ # Get issue JSON
325
+ local json
326
+ json="$(bd show "$issue_id" --json 2>&1)" || {
327
+ echo "Error: Failed to retrieve issue ${issue_id}" >&2
328
+ exit 1
329
+ }
330
+
331
+ # Check for bd error patterns
332
+ if printf '%s' "$json" | grep -Eqi '^Error|Error resolving|Error fetching'; then
333
+ echo "Error: Issue not found: ${issue_id}" >&2
334
+ exit 1
335
+ fi
336
+
337
+ if [[ -z "$json" || "$json" == "[]" || "$json" == "null" ]]; then
338
+ echo "Error: Issue not found: ${issue_id}" >&2
339
+ exit 1
340
+ fi
341
+
342
+ # Extract fields — prefer jq, fall back to grep/sed
343
+ local description="" design=""
344
+ if command -v jq &>/dev/null; then
345
+ description="$(printf '%s' "$json" | jq -r 'if type == "array" then .[0].description else .description end // empty' 2>/dev/null || true)"
346
+ design="$(printf '%s' "$json" | jq -r 'if type == "array" then .[0].design else .design end // empty' 2>/dev/null || true)"
347
+ else
348
+ description="$(printf '%s' "$json" | grep -o '"description": *"[^"]*"' | head -1 | sed 's/^"description": *"//;s/"$//' || true)"
349
+ design="$(printf '%s' "$json" | grep -o '"design": *"[^"]*"' | head -1 | sed 's/^"design": *"//;s/"$//' || true)"
350
+ fi
351
+
352
+ # Check 1: Issue has description
353
+ if [[ -z "$description" ]]; then
354
+ echo "Warning: Issue ${issue_id} has no description"
355
+ warnings=$((warnings + 1))
356
+ fi
357
+
358
+ # Get comments to check for stage transitions
359
+ local comments
360
+ comments="$(bd comments list "$issue_id" 2>/dev/null || true)"
361
+
362
+ # Check 2: At least one stage transition exists
363
+ local has_transition=false
364
+ if echo "$comments" | grep -q 'Stage:.*complete'; then
365
+ has_transition=true
366
+ fi
367
+
368
+ if [[ "$has_transition" == "false" ]]; then
369
+ echo "Warning: No stage transition found on issue ${issue_id}"
370
+ warnings=$((warnings + 1))
371
+ fi
372
+
373
+ # Check 3: Most recent transition has summary
374
+ # Isolate the last transition block to avoid false-positives from earlier summaries
375
+ if [[ "$has_transition" == "true" ]]; then
376
+ local has_summary=false
377
+ local last_block
378
+ last_block="$(echo "$comments" | awk '/Stage:.*complete/{found=1; block=""} found{block=block"\n"$0} END{print block}')"
379
+ if echo "$last_block" | grep -q 'Summary:'; then
380
+ has_summary=true
381
+ fi
382
+ if [[ "$has_summary" == "false" ]]; then
383
+ echo "Warning: Most recent stage transition has no summary on issue ${issue_id}"
384
+ warnings=$((warnings + 1))
385
+ fi
386
+ fi
387
+
388
+ # Check 4: Design metadata is set if past plan stage
389
+ # "Past plan" means there's a transition FROM plan or any later stage
390
+ local past_plan=false
391
+ if echo "$comments" | grep -q 'Stage: plan complete'; then
392
+ past_plan=true
393
+ fi
394
+ if echo "$comments" | grep -Eq 'Stage: (dev|validate|ship|review|premerge) complete'; then
395
+ past_plan=true
396
+ fi
397
+
398
+ if [[ "$past_plan" == "true" && -z "$design" ]]; then
399
+ echo "Warning: Design metadata not set on issue ${issue_id} (past plan stage)"
400
+ warnings=$((warnings + 1))
401
+ fi
402
+
403
+ # Summary
404
+ if [[ $warnings -eq 0 ]]; then
405
+ echo "All context fields present on issue ${issue_id}"
406
+ else
407
+ echo "${warnings} warning(s) found on issue ${issue_id}"
408
+ fi
409
+
410
+ # Always exit 0 — advisory only
411
+ exit 0
412
+ }
413
+
272
414
  # ── Main dispatcher ──────────────────────────────────────────────────────
273
415
 
274
416
  if [[ $# -lt 1 ]]; then
@@ -284,6 +426,7 @@ case "$subcommand" in
284
426
  update-progress) cmd_update_progress "$@" ;;
285
427
  parse-progress) cmd_parse_progress "$@" ;;
286
428
  stage-transition) cmd_stage_transition "$@" ;;
429
+ validate) cmd_validate "$@" ;;
287
430
  *)
288
431
  echo "Error: Unknown subcommand '${subcommand}'" >&2
289
432
  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;
@@ -19,8 +19,110 @@
19
19
  */
20
20
 
21
21
  const { syncCommands } = require('./sync-commands');
22
+ const { AGENT_ADAPTERS } = require('./sync-commands');
22
23
  const path = require('path');
23
24
  const fs = require('fs');
25
+ const { validatePluginSchema } = require('../lib/plugin-manager');
26
+
27
+ const SYNC_ADAPTER_BY_PLUGIN_ID = Object.freeze({
28
+ claude: 'claude-code',
29
+ cline: 'cline',
30
+ codex: 'codex',
31
+ copilot: 'github-copilot',
32
+ cursor: 'cursor',
33
+ kilocode: 'kilo-code',
34
+ opencode: 'opencode',
35
+ roo: 'roo-code',
36
+ });
37
+
38
+ function normalizeRelativeDir(dir) {
39
+ if (!dir) return null;
40
+ return String(dir).replace(/\\/g, '/').replace(/\/+$/, '');
41
+ }
42
+
43
+ function getDeclaredCommandDir(plugin) {
44
+ const dirs = plugin.directories || {};
45
+ return dirs.commands || dirs.workflows || dirs.prompts || dirs.skills || null;
46
+ }
47
+
48
+ function getExpectedCommandDir(plugin) {
49
+ const adapterId = SYNC_ADAPTER_BY_PLUGIN_ID[plugin.id];
50
+ if (!adapterId) {
51
+ return null;
52
+ }
53
+
54
+ const adapter = AGENT_ADAPTERS[adapterId];
55
+ if (!adapter) {
56
+ return null;
57
+ }
58
+
59
+ if (plugin.id === 'codex') {
60
+ return normalizeRelativeDir(plugin.directories?.skills || adapter.baseDir);
61
+ }
62
+
63
+ return normalizeRelativeDir(adapter.baseDir);
64
+ }
65
+
66
+ function validatePluginParity(plugin, errors) {
67
+ const validation = validatePluginSchema(plugin);
68
+ if (!validation.valid) {
69
+ const parityErrors = validation.errors.filter((error) =>
70
+ error.startsWith('"support') || error.startsWith('"capabilities')
71
+ );
72
+ parityErrors.forEach((error) => {
73
+ errors.push(`Plugin "${plugin.name || plugin.id}" (${plugin.id || 'unknown'}): ${error}`);
74
+ });
75
+ }
76
+
77
+ const capabilities = plugin.capabilities || {};
78
+ const dirs = plugin.directories || {};
79
+ const supportStatus = plugin.support?.status;
80
+
81
+ if (capabilities.commands) {
82
+ const expectedDir = getExpectedCommandDir(plugin);
83
+ const declaredDir = normalizeRelativeDir(getDeclaredCommandDir(plugin));
84
+
85
+ if (!expectedDir) {
86
+ errors.push(
87
+ `Plugin "${plugin.name}" (${plugin.id}) declares commands support but has no Forge sync adapter.`
88
+ );
89
+ } else if (!declaredDir) {
90
+ errors.push(
91
+ `Plugin "${plugin.name}" (${plugin.id}) declares commands support but has no command directory configured.`
92
+ );
93
+ } else if (declaredDir !== expectedDir) {
94
+ errors.push(
95
+ `Plugin "${plugin.name}" (${plugin.id}) command directory "${declaredDir}" does not match sync output "${expectedDir}".`
96
+ );
97
+ }
98
+ }
99
+
100
+ if (capabilities.rules && !dirs.rules) {
101
+ errors.push(
102
+ `Plugin "${plugin.name}" (${plugin.id}) declares rules capability but has no scaffold path configured.`
103
+ );
104
+ }
105
+
106
+ if (capabilities.skills && !dirs.skills) {
107
+ errors.push(
108
+ `Plugin "${plugin.name}" (${plugin.id}) declares skills capability but has no scaffold path configured.`
109
+ );
110
+ }
111
+
112
+ if (supportStatus === 'deprecated' || supportStatus === 'unsupported') {
113
+ if (capabilities.skills) {
114
+ errors.push(
115
+ `Plugin "${plugin.name}" (${plugin.id}) is ${supportStatus} but still declares skills parity.`
116
+ );
117
+ }
118
+
119
+ if (plugin.setup?.createSkill || dirs.skills) {
120
+ errors.push(
121
+ `Plugin "${plugin.name}" (${plugin.id}) is ${supportStatus} but still scaffolds Forge skills.`
122
+ );
123
+ }
124
+ }
125
+ }
24
126
 
25
127
  /**
26
128
  * Validate all agent configs are complete and in sync.
@@ -89,6 +191,7 @@ function checkAgents(repoRoot) {
89
191
  try {
90
192
  const plugin = JSON.parse(fs.readFileSync(path.join(pluginDir, file), 'utf8'));
91
193
  plugins.push(plugin);
194
+ validatePluginParity(plugin, errors);
92
195
  } catch (_err) {
93
196
  errors.push(`Failed to parse plugin file: lib/agents/${file}`);
94
197
  }
@@ -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