forge-workflow 0.0.6 → 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 (45) hide show
  1. package/.cursorrules +149 -0
  2. package/bin/forge.js +36 -3
  3. package/lib/agents/README.md +46 -1
  4. package/lib/agents/cline.plugin.json +11 -4
  5. package/lib/agents/codex.plugin.json +2 -2
  6. package/lib/agents/copilot.plugin.json +5 -5
  7. package/lib/agents/cursor.plugin.json +1 -1
  8. package/lib/agents/kilocode.plugin.json +1 -1
  9. package/lib/agents/opencode.plugin.json +7 -4
  10. package/lib/agents/roo.plugin.json +10 -3
  11. package/lib/agents-config.js +127 -79
  12. package/lib/codex-skills.js +50 -0
  13. package/lib/commands/_registry.js +40 -1
  14. package/lib/commands/commands-reset.js +147 -0
  15. package/lib/commands/dev.js +26 -0
  16. package/lib/commands/plan.js +18 -0
  17. package/lib/commands/setup.js +4295 -0
  18. package/lib/commands/ship.js +20 -0
  19. package/lib/commands/status.js +210 -44
  20. package/lib/commands/sync.js +17 -1
  21. package/lib/commands/validate.js +13 -0
  22. package/lib/detect-agent.js +38 -8
  23. package/lib/detection-utils.js +405 -0
  24. package/lib/file-utils.js +260 -0
  25. package/lib/forge-context.js +42 -0
  26. package/lib/frontmatter.js +79 -0
  27. package/lib/husky-migration.js +113 -12
  28. package/lib/lefthook-check.js +27 -6
  29. package/lib/plugin-manager.js +225 -72
  30. package/lib/project-discovery.js +39 -5
  31. package/lib/runtime-health.js +305 -0
  32. package/lib/shell-utils.js +50 -0
  33. package/lib/ui-utils.js +43 -0
  34. package/lib/validation-utils.js +163 -0
  35. package/lib/workflow/enforce-stage.js +179 -0
  36. package/lib/workflow/stages.js +201 -0
  37. package/lib/workflow/state.js +332 -0
  38. package/opencode.json +67 -0
  39. package/package.json +15 -5
  40. package/scripts/beads-context.sh +12 -4
  41. package/scripts/check-agents.js +103 -0
  42. package/scripts/pr-coordinator.sh +71 -21
  43. package/scripts/smart-status.sh +21 -11
  44. package/scripts/sync-commands.js +49 -20
  45. package/scripts/test.js +16 -1
package/opencode.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "permission": {
4
+ "*": "ask",
5
+ "bash": {
6
+ "*": "ask",
7
+ "git status *": "allow",
8
+ "git log *": "allow",
9
+ "git diff *": "allow",
10
+ "git branch *": "allow",
11
+ "git show *": "allow",
12
+ "git remote *": "allow",
13
+ "git stash *": "allow",
14
+ "git tag *": "allow",
15
+ "git rev-parse *": "allow",
16
+ "git describe *": "allow",
17
+ "git ls-files *": "allow",
18
+ "git blame *": "allow",
19
+ "git shortlog *": "allow",
20
+ "git add *": "allow",
21
+ "git commit *": "allow",
22
+ "git push *": "allow",
23
+ "git checkout *": "allow",
24
+ "git switch *": "allow",
25
+ "git merge *": "allow",
26
+ "git rebase *": "ask",
27
+ "git cherry-pick *": "allow",
28
+ "bd *": "allow",
29
+ "gh *": "allow",
30
+ "ls *": "allow",
31
+ "cat *": "allow",
32
+ "grep *": "allow",
33
+ "find *": "allow",
34
+ "pwd *": "allow",
35
+ "which *": "allow",
36
+ "echo *": "allow",
37
+ "head *": "allow",
38
+ "tail *": "allow",
39
+ "sort *": "allow",
40
+ "wc *": "allow",
41
+ "awk *": "allow",
42
+ "sed *": "allow",
43
+ "stat *": "allow",
44
+ "du *": "allow",
45
+ "df *": "allow",
46
+ "file *": "allow",
47
+ "mkdir *": "allow",
48
+ "touch *": "allow",
49
+ "cp *": "allow",
50
+ "mv *": "allow",
51
+ "bun *": "allow",
52
+ "bunx *": "allow",
53
+ "npm *": "allow",
54
+ "npx *": "allow",
55
+ "node *": "allow",
56
+
57
+ "git worktree *": "allow",
58
+ "git reset --hard *": "ask",
59
+ "rm -rf *": "deny",
60
+ "git push --force *": "deny",
61
+ "git push -f *": "deny"
62
+ },
63
+ "edit": {
64
+ "*": "allow"
65
+ }
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "7-stage TDD workflow for ALL AI coding agents (Claude, Cursor, Cline, OpenCode, Copilot, Kilo Code, Roo Code, Codex)",
5
5
  "bin": {
6
6
  "forge": "bin/forge.js",
@@ -43,8 +43,8 @@
43
43
  "eslint": "^10.0.2",
44
44
  "globals": "^17.3.0",
45
45
  "js-yaml": "^4.1.1",
46
- "lefthook": "^1.10.1",
47
- "yaml": "^2.8.2"
46
+ "lefthook": "^2.1.4",
47
+ "yaml": "^2.8.3"
48
48
  },
49
49
  "keywords": [
50
50
  "ai",
@@ -81,11 +81,17 @@
81
81
  ".claude/scripts/",
82
82
  ".claude/skills/",
83
83
  ".cursor/",
84
+ "!.cursor/node_modules/",
84
85
  ".cline/",
86
+ "!.cline/node_modules/",
85
87
  ".roo/",
88
+ "!.roo/node_modules/",
86
89
  ".codex/",
90
+ "!.codex/node_modules/",
87
91
  ".kilocode/",
92
+ "!.kilocode/node_modules/",
88
93
  ".opencode/",
94
+ "!.opencode/node_modules/",
89
95
  "scripts/",
90
96
  ".github/prompts/",
91
97
  ".github/PLUGIN_TEMPLATE.json",
@@ -97,7 +103,9 @@
97
103
  "lefthook.yml",
98
104
  "AGENTS.md",
99
105
  "CLAUDE.md",
100
- ".mcp.json.example"
106
+ ".mcp.json.example",
107
+ ".cursorrules",
108
+ "opencode.json"
101
109
  ],
102
110
  "dependencies": {
103
111
  "@babel/parser": "^7.29.2",
@@ -106,7 +114,9 @@
106
114
  "overrides": {
107
115
  "eslint": "^10.0.2",
108
116
  "flatted": "3.4.2",
109
- "minimatch": "10.2.4"
117
+ "minimatch": "10.2.4",
118
+ "brace-expansion": "5.0.5",
119
+ "lodash": "4.18.1"
110
120
  },
111
121
  "c8": {
112
122
  "lines": 80,
@@ -7,7 +7,7 @@
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> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."]
10
+ # stage-transition <issue-id> <completed-stage> <next-stage> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."] [--workflow-state "{...}"]
11
11
  # validate <issue-id>
12
12
  #
13
13
  # Cross-platform: works on Windows (Git Bash), macOS, and Linux.
@@ -30,7 +30,7 @@ Subcommands:
30
30
  set-acceptance <issue-id> "<criteria-text>"
31
31
  update-progress <issue-id> <task-num> <total> "<title>" <commit-sha> <test-count> <gate-count>
32
32
  parse-progress <issue-id>
33
- stage-transition <issue-id> <completed-stage> <next-stage> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."]
33
+ stage-transition <issue-id> <completed-stage> <next-stage> [--summary "..."] [--decisions "..."] [--artifacts "..."] [--next "..."] [--workflow-state "{...}"]
34
34
  validate <issue-id>
35
35
  EOF
36
36
  exit 1
@@ -238,7 +238,7 @@ cmd_parse_progress() {
238
238
 
239
239
  cmd_stage_transition() {
240
240
  if [[ $# -lt 3 ]]; then
241
- echo "Usage: beads-context.sh stage-transition <issue-id> <completed-stage> <next-stage> [--summary \"...\"] [--decisions \"...\"] [--artifacts \"...\"] [--next \"...\"]" >&2
241
+ echo "Usage: beads-context.sh stage-transition <issue-id> <completed-stage> <next-stage> [--summary \"...\"] [--decisions \"...\"] [--artifacts \"...\"] [--next \"...\"] [--workflow-state \"{...}\"]" >&2
242
242
  exit 1
243
243
  fi
244
244
 
@@ -250,7 +250,7 @@ cmd_stage_transition() {
250
250
  shift 3
251
251
 
252
252
  # Parse optional flags
253
- local flag_summary="" flag_decisions="" flag_artifacts="" flag_next=""
253
+ local flag_summary="" flag_decisions="" flag_artifacts="" flag_next="" flag_workflow_state=""
254
254
  while [[ $# -gt 0 ]]; do
255
255
  case "$1" in
256
256
  --summary)
@@ -269,6 +269,10 @@ cmd_stage_transition() {
269
269
  shift
270
270
  flag_next="$(sanitize "${1:-}")"
271
271
  ;;
272
+ --workflow-state)
273
+ shift
274
+ flag_workflow_state="$(sanitize_config_value "${1:-}")"
275
+ ;;
272
276
  *)
273
277
  # Ignore unknown flags for forward compatibility
274
278
  ;;
@@ -296,6 +300,10 @@ Artifacts: ${flag_artifacts}"
296
300
  comment="${comment}
297
301
  Next: ${flag_next}"
298
302
  fi
303
+ if [[ -n "$flag_workflow_state" ]]; then
304
+ comment="${comment}
305
+ WorkflowState: ${flag_workflow_state}"
306
+ fi
299
307
 
300
308
  if ! bd_comment "$issue_id" "$comment" > /dev/null; then
301
309
  die "Failed to record stage transition on issue ${issue_id}"
@@ -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
  }
@@ -563,9 +563,54 @@ cmd_auto_label() {
563
563
  echo "No label changes needed for PR #$pr_number"
564
564
  fi
565
565
  }
566
+ collect_worktree_entries() {
567
+ local git_cmd porcelain current_path current_branch entries line separator
568
+ entries=""
569
+ current_path=""
570
+ current_branch=""
571
+ separator=$'\t'
572
+ git_cmd="${GIT_CMD:-git}"
573
+ porcelain="$("$git_cmd" worktree list --porcelain 2>/dev/null || true)"
574
+
575
+ while IFS= read -r line; do
576
+ case "$line" in
577
+ worktree\ *)
578
+ current_path="${line#worktree }"
579
+ ;;
580
+ branch\ refs/heads/*)
581
+ current_branch="${line#branch refs/heads/}"
582
+ ;;
583
+ "")
584
+ if [[ -n "$current_path" ]]; then
585
+ if [[ -n "$entries" ]]; then
586
+ entries+=$'\n'
587
+ fi
588
+ entries+="${current_path}${separator}${current_branch}"
589
+ fi
590
+ current_path=""
591
+ current_branch=""
592
+ ;;
593
+ esac
594
+ done <<EOF
595
+ $porcelain
596
+
597
+ EOF
598
+
599
+ if [[ -n "$current_path" ]]; then
600
+ if [[ -n "$entries" ]]; then
601
+ entries+=$'\n'
602
+ fi
603
+ entries+="${current_path}${separator}${current_branch}"
604
+ fi
605
+
606
+ printf '%s' "$entries"
607
+ }
608
+
566
609
  cmd_stale_worktrees() {
567
610
  local threshold_hours=48
568
- local worktrees_dir=".worktrees"
611
+ local worktree_filter=""
612
+ local git_cmd
613
+ git_cmd="${GIT_CMD:-git}"
569
614
 
570
615
  while [[ $# -gt 0 ]]; do
571
616
  case "$1" in
@@ -578,17 +623,19 @@ cmd_stale_worktrees() {
578
623
  return 1
579
624
  fi
580
625
  shift ;;
581
- --dir=*) worktrees_dir="${1#--dir=}"; shift ;;
626
+ --dir=*) worktree_filter="${1#--dir=}"; shift ;;
582
627
  *) echo "Error: unknown flag '$1'" >&2; return 1 ;;
583
628
  esac
584
629
  done
585
630
 
586
631
  local repo_root
587
- repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || repo_root="."
588
- local wt_path="$repo_root/$worktrees_dir"
632
+ repo_root="$("$git_cmd" rev-parse --show-toplevel 2>/dev/null)" || repo_root="."
633
+
634
+ local worktree_entries
635
+ worktree_entries="$(collect_worktree_entries)"
589
636
 
590
- if [[ ! -d "$wt_path" ]]; then
591
- echo "No worktrees found (directory $worktrees_dir does not exist)"
637
+ if [[ -z "$worktree_entries" ]]; then
638
+ echo "No worktrees found"
592
639
  return 0
593
640
  fi
594
641
 
@@ -596,28 +643,29 @@ cmd_stale_worktrees() {
596
643
  local found_any=0
597
644
  local stale_count=0
598
645
 
599
- for entry in "$wt_path"/*/; do
600
- [[ -d "$entry" ]] || continue
601
- found_any=1
646
+ while IFS=$'\t' read -r entry_path entry_branch; do
647
+ [[ -n "$entry_path" ]] || continue
602
648
 
603
- # Resolve real path and verify it's under repo root
604
- local real_path
605
- real_path="$(realpath "$entry" 2>/dev/null)" || continue
606
- local real_root
607
- real_root="$(realpath "$repo_root" 2>/dev/null)" || continue
649
+ if [[ "$entry_path" == "$repo_root" ]]; then
650
+ continue
651
+ fi
608
652
 
609
- if [[ "$real_path" != "$real_root/"* ]]; then
610
- echo "WARNING: skipping symlink outside repo: $entry" >&2
653
+ if [[ -n "$worktree_filter" ]] && [[ "$entry_path" != *"$worktree_filter"* ]]; then
611
654
  continue
612
655
  fi
613
656
 
657
+ found_any=1
658
+
614
659
  # Get last commit date
615
660
  local last_commit_date
616
- last_commit_date="$(git -C "$entry" log -1 --format=%ci 2>/dev/null)" || continue
661
+ last_commit_date="$("$git_cmd" -C "$entry_path" log -1 --format=%ci 2>/dev/null)" || continue
617
662
 
618
663
  # Get branch name
619
- local branch_name
620
- branch_name="$(git -C "$entry" branch --show-current 2>/dev/null)" || branch_name="unknown"
664
+ local branch_name="${entry_branch:-}"
665
+ if [[ -z "$branch_name" ]]; then
666
+ branch_name="$("$git_cmd" -C "$entry_path" branch --show-current 2>/dev/null)" || branch_name="unknown"
667
+ fi
668
+ branch_name="${branch_name:-unknown}"
621
669
 
622
670
  # Calculate age in hours
623
671
  local last_epoch now_epoch
@@ -628,10 +676,12 @@ cmd_stale_worktrees() {
628
676
  if [[ "$age_hours" -ge "$threshold_hours" ]]; then
629
677
  stale_count=$((stale_count + 1))
630
678
  local dir_name
631
- dir_name="$(basename "$entry")"
679
+ dir_name="$(basename "$entry_path")"
632
680
  echo "STALE: $dir_name (branch: $branch_name, last commit: ${age_hours}h ago)"
633
681
  fi
634
- done
682
+ done <<EOF
683
+ $worktree_entries
684
+ EOF
635
685
 
636
686
  if [[ "$found_any" -eq 0 ]]; then
637
687
  echo "No worktrees found"
@@ -20,8 +20,24 @@
20
20
 
21
21
  set -euo pipefail
22
22
 
23
+ # ── Source cross-dev awareness scripts ────────────────────────────────
24
+ # file-index.sh: file_index_read for reading file index entries
25
+ # sync-utils.sh: get_session_identity for current developer identity
26
+
27
+ _SMART_STATUS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ if [ -f "$_SMART_STATUS_DIR/lib/sanitize.sh" ]; then
29
+ source "$_SMART_STATUS_DIR/lib/sanitize.sh"
30
+ fi
31
+ if [ -f "$_SMART_STATUS_DIR/file-index.sh" ]; then
32
+ source "$_SMART_STATUS_DIR/file-index.sh"
33
+ fi
34
+ if [ -f "$_SMART_STATUS_DIR/sync-utils.sh" ]; then
35
+ source "$_SMART_STATUS_DIR/sync-utils.sh"
36
+ fi
37
+
23
38
  # ── Helpers ──────────────────────────────────────────────────────────────
24
39
 
40
+ if ! declare -F sanitize >/dev/null; then
25
41
  # Sanitize a string: strip shell-injection patterns (OWASP A03)
26
42
  # Removes: double quotes, $(...), backticks, semicolons, and newlines
27
43
  sanitize() {
@@ -38,17 +54,6 @@ sanitize() {
38
54
  val="$(printf '%s' "$val" | tr '\n' ' ')"
39
55
  printf '%s' "$val"
40
56
  }
41
-
42
- # ── Source cross-dev awareness scripts ────────────────────────────────
43
- # file-index.sh: file_index_read for reading file index entries
44
- # sync-utils.sh: get_session_identity for current developer identity
45
-
46
- _SMART_STATUS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
47
- if [ -f "$_SMART_STATUS_DIR/file-index.sh" ]; then
48
- source "$_SMART_STATUS_DIR/file-index.sh"
49
- fi
50
- if [ -f "$_SMART_STATUS_DIR/sync-utils.sh" ]; then
51
- source "$_SMART_STATUS_DIR/sync-utils.sh"
52
57
  fi
53
58
 
54
59
  # ── Dependency check ────────────────────────────────────────────────────
@@ -117,6 +122,11 @@ done
117
122
  # not found" error, runs bd init --force + bd backup restore, then retries.
118
123
  # Capture stderr into variable (2>&1 redirects stderr to stdout for capture,
119
124
  # then >/dev/null discards original stdout so only errors remain).
125
+ if ! command -v "$BD" >/dev/null 2>&1; then
126
+ echo "Error: bd is required but not found." >&2
127
+ exit 1
128
+ fi
129
+
120
130
  _bd_list_err="$("$BD" list --json --limit 0 2>&1 >/dev/null || true)"
121
131
  if printf '%s' "$_bd_list_err" | grep -qi "database.*not found"; then
122
132
  # Derive prefix from repo root directory name (not pwd, which could be a subdirectory)
@@ -12,6 +12,10 @@
12
12
  */
13
13
 
14
14
  const YAML = require('yaml');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const { listCodexSkillEntries } = require('../lib/codex-skills');
15
19
 
16
20
  /**
17
21
  * Parse YAML frontmatter from a markdown string.
@@ -293,9 +297,14 @@ function adaptForAgent(agentName, frontmatter, body, commandName) {
293
297
 
294
298
  // ---- Sync logic -----------------------------------------------------------------
295
299
 
296
- const fs = require('fs');
297
- const path = require('path');
298
- const crypto = require('crypto');
300
+ function resolveCanonicalCommandsDir(repoRoot) {
301
+ const candidates = [
302
+ path.join(repoRoot, 'commands'),
303
+ path.join(repoRoot, '.claude', 'commands'),
304
+ ];
305
+
306
+ return candidates.find(candidate => fs.existsSync(candidate)) || null;
307
+ }
299
308
 
300
309
  /**
301
310
  * Compute a content hash for change detection.
@@ -310,6 +319,25 @@ function contentHash(content) {
310
319
  return crypto.createHash('sha256').update(normalized).digest('hex');
311
320
  }
312
321
 
322
+ /**
323
+ * Write the sync manifest for generated entries without rewriting command files.
324
+ *
325
+ * @param {string} repoRoot
326
+ * @param {SyncEntry[]} entries
327
+ */
328
+ function writeSyncManifest(repoRoot, entries) {
329
+ const manifestDir = path.join(repoRoot, '.forge');
330
+ fs.mkdirSync(manifestDir, { recursive: true });
331
+ const manifestData = {
332
+ generatedAt: new Date().toISOString(),
333
+ files: entries.map((e) => path.relative(repoRoot, e.filePath).replace(/\\/g, '/')),
334
+ };
335
+ fs.writeFileSync(
336
+ path.join(manifestDir, 'sync-manifest.json'),
337
+ JSON.stringify(manifestData, null, 2) + '\n'
338
+ );
339
+ }
340
+
313
341
  /**
314
342
  * @typedef {Object} SyncEntry
315
343
  * @property {string} agent - Agent slug
@@ -339,11 +367,11 @@ function contentHash(content) {
339
367
  * - `check: true` — compares generated content with existing files, reports mismatches
340
368
  * - default (both false) — writes files, creating directories as needed
341
369
  *
342
- * @param {{ dryRun: boolean, check: boolean, repoRoot: string }} options
370
+ * @param {{ dryRun: boolean, check: boolean, repoRoot: string, canonicalDir?: string | null }} options
343
371
  * @returns {SyncResult}
344
372
  */
345
- function syncCommands({ dryRun, check, repoRoot }) {
346
- const commandsDir = path.join(repoRoot, '.claude', 'commands');
373
+ function syncCommands({ dryRun, check, repoRoot, canonicalDir = null }) {
374
+ const commandsDir = canonicalDir || resolveCanonicalCommandsDir(repoRoot) || path.join(repoRoot, '.claude', 'commands');
347
375
 
348
376
  // Read all .md files from the commands directory
349
377
  /** @type {string[]} */
@@ -474,16 +502,7 @@ function syncCommands({ dryRun, check, repoRoot }) {
474
502
 
475
503
  // Write sync manifest — records every file generated so stale detection
476
504
  // can identify orphaned files without false-flagging custom files.
477
- const manifestDir = path.join(repoRoot, '.forge');
478
- fs.mkdirSync(manifestDir, { recursive: true });
479
- const manifestData = {
480
- generatedAt: new Date().toISOString(),
481
- files: written.map((e) => path.relative(repoRoot, e.filePath).replace(/\\/g, '/')),
482
- };
483
- fs.writeFileSync(
484
- path.join(manifestDir, 'sync-manifest.json'),
485
- JSON.stringify(manifestData, null, 2) + '\n'
486
- );
505
+ writeSyncManifest(repoRoot, written);
487
506
 
488
507
  return { written, overwritten };
489
508
  }
@@ -506,7 +525,7 @@ if (require.main === module) {
506
525
 
507
526
  if (dryRun) {
508
527
  if (result.planned.length === 0) {
509
- console.log('No command files found in .claude/commands/');
528
+ console.log('No command files found in commands/ or .claude/commands/');
510
529
  } else {
511
530
  console.log('Dry run — files that would be generated:\n');
512
531
  for (const entry of result.planned) {
@@ -516,7 +535,7 @@ if (require.main === module) {
516
535
  }
517
536
  } else if (check) {
518
537
  if (result.empty) {
519
- console.error('Error: no command files found in .claude/commands/ — cannot verify sync.');
538
+ console.error('Error: no command files found in commands/ or .claude/commands/ — cannot verify sync.');
520
539
  process.exit(1);
521
540
  }
522
541
  if (result.manifestMissing) {
@@ -561,11 +580,21 @@ if (require.main === module) {
561
580
  }
562
581
 
563
582
  if (result.written.length === 0) {
564
- console.log('No command files found in .claude/commands/');
583
+ console.log('No command files found in commands/ or .claude/commands/');
565
584
  } else {
566
585
  console.log(`Synced ${result.written.length} file(s) across agents.`);
567
586
  }
568
587
  }
569
588
  }
570
589
 
571
- module.exports = { parseFrontmatter, buildFile, AGENT_ADAPTERS, adaptForAgent, syncCommands };
590
+ module.exports = {
591
+ parseFrontmatter,
592
+ buildFile,
593
+ AGENT_ADAPTERS,
594
+ adaptForAgent,
595
+ listCodexSkillEntries,
596
+ syncCommands,
597
+ contentHash,
598
+ resolveCanonicalCommandsDir,
599
+ writeSyncManifest,
600
+ };
package/scripts/test.js CHANGED
@@ -22,9 +22,24 @@ function detectPackageManager() {
22
22
  const pkgManager = detectPackageManager();
23
23
  console.log(`🧪 Running test suite (${pkgManager} test)...`);
24
24
 
25
+ // Strip git hook environment variables so child processes (especially tests
26
+ // that create temp git repos) never accidentally operate on the real worktree.
27
+ // During pre-push hooks, git sets GIT_DIR pointing to the repo — any test that
28
+ // runs `git init` / `git commit` in a temp dir inherits this and silently
29
+ // commits into the worktree instead, creating rogue "initial commit" that
30
+ // deletes the entire codebase.
31
+ const env = { ...process.env };
32
+ for (const key of Object.keys(env)) {
33
+ if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
34
+ || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
35
+ || key === 'GIT_QUARANTINE_PATH') {
36
+ delete env[key];
37
+ }
38
+ }
39
+
25
40
  // Use 'run test' to invoke the package.json script (which may include --timeout flags)
26
41
  // 'bun test' is a built-in that ignores package.json scripts
27
- const result = spawnSync(pkgManager, ['run', 'test'], { stdio: 'inherit', shell: isWindows });
42
+ const result = spawnSync(pkgManager, ['run', 'test'], { stdio: 'inherit', shell: isWindows, env });
28
43
 
29
44
  if (result.error) {
30
45
  console.error('');