specweave 1.0.235 → 1.0.239

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 (196) hide show
  1. package/README.md +89 -193
  2. package/dist/plugins/specweave-github/lib/github-ac-comment-poster.d.ts +37 -0
  3. package/dist/plugins/specweave-github/lib/github-ac-comment-poster.d.ts.map +1 -0
  4. package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js +176 -0
  5. package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js.map +1 -0
  6. package/dist/plugins/specweave-github/lib/github-batch-sync.d.ts +36 -0
  7. package/dist/plugins/specweave-github/lib/github-batch-sync.d.ts.map +1 -0
  8. package/dist/plugins/specweave-github/lib/github-batch-sync.js +115 -0
  9. package/dist/plugins/specweave-github/lib/github-batch-sync.js.map +1 -0
  10. package/dist/plugins/specweave-github/lib/github-board-resolver-v2.d.ts +37 -0
  11. package/dist/plugins/specweave-github/lib/github-board-resolver-v2.d.ts.map +1 -0
  12. package/dist/plugins/specweave-github/lib/github-board-resolver-v2.js +56 -0
  13. package/dist/plugins/specweave-github/lib/github-board-resolver-v2.js.map +1 -0
  14. package/dist/plugins/specweave-github/lib/github-conflict-resolver.d.ts +68 -0
  15. package/dist/plugins/specweave-github/lib/github-conflict-resolver.d.ts.map +1 -0
  16. package/dist/plugins/specweave-github/lib/github-conflict-resolver.js +102 -0
  17. package/dist/plugins/specweave-github/lib/github-conflict-resolver.js.map +1 -0
  18. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.d.ts +64 -0
  19. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.d.ts.map +1 -0
  20. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js +162 -0
  21. package/dist/plugins/specweave-github/lib/github-cross-repo-sync.js.map +1 -0
  22. package/dist/plugins/specweave-github/lib/github-field-sync.d.ts +50 -0
  23. package/dist/plugins/specweave-github/lib/github-field-sync.d.ts.map +1 -0
  24. package/dist/plugins/specweave-github/lib/github-field-sync.js +107 -0
  25. package/dist/plugins/specweave-github/lib/github-field-sync.js.map +1 -0
  26. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts +53 -0
  27. package/dist/plugins/specweave-github/lib/github-graphql-client.d.ts.map +1 -0
  28. package/dist/plugins/specweave-github/lib/github-graphql-client.js +138 -0
  29. package/dist/plugins/specweave-github/lib/github-graphql-client.js.map +1 -0
  30. package/dist/plugins/specweave-github/lib/github-issue-body-generator.d.ts +40 -0
  31. package/dist/plugins/specweave-github/lib/github-issue-body-generator.d.ts.map +1 -0
  32. package/dist/plugins/specweave-github/lib/github-issue-body-generator.js +50 -0
  33. package/dist/plugins/specweave-github/lib/github-issue-body-generator.js.map +1 -0
  34. package/dist/plugins/specweave-github/lib/github-issue-body-parser.d.ts +30 -0
  35. package/dist/plugins/specweave-github/lib/github-issue-body-parser.d.ts.map +1 -0
  36. package/dist/plugins/specweave-github/lib/github-issue-body-parser.js +75 -0
  37. package/dist/plugins/specweave-github/lib/github-issue-body-parser.js.map +1 -0
  38. package/dist/plugins/specweave-github/lib/github-pull-sync.d.ts +94 -0
  39. package/dist/plugins/specweave-github/lib/github-pull-sync.d.ts.map +1 -0
  40. package/dist/plugins/specweave-github/lib/github-pull-sync.js +232 -0
  41. package/dist/plugins/specweave-github/lib/github-pull-sync.js.map +1 -0
  42. package/dist/plugins/specweave-github/lib/github-push-sync.d.ts +50 -0
  43. package/dist/plugins/specweave-github/lib/github-push-sync.d.ts.map +1 -0
  44. package/dist/plugins/specweave-github/lib/github-push-sync.js +114 -0
  45. package/dist/plugins/specweave-github/lib/github-push-sync.js.map +1 -0
  46. package/dist/plugins/specweave-github/lib/github-rate-limiter.d.ts +53 -0
  47. package/dist/plugins/specweave-github/lib/github-rate-limiter.d.ts.map +1 -0
  48. package/dist/plugins/specweave-github/lib/github-rate-limiter.js +109 -0
  49. package/dist/plugins/specweave-github/lib/github-rate-limiter.js.map +1 -0
  50. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.d.ts +21 -0
  51. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.d.ts.map +1 -0
  52. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +161 -0
  53. package/dist/plugins/specweave-github/lib/github-spec-frontmatter-updater.js.map +1 -0
  54. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts +46 -0
  55. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.d.ts.map +1 -0
  56. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js +99 -0
  57. package/dist/plugins/specweave-github/lib/github-sync-orchestrator.js.map +1 -0
  58. package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts +43 -0
  59. package/dist/plugins/specweave-github/lib/github-us-auto-closer.d.ts.map +1 -0
  60. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js +153 -0
  61. package/dist/plugins/specweave-github/lib/github-us-auto-closer.js.map +1 -0
  62. package/dist/plugins/specweave-github/lib/index.d.ts +1 -4
  63. package/dist/plugins/specweave-github/lib/index.d.ts.map +1 -1
  64. package/dist/plugins/specweave-github/lib/index.js +1 -4
  65. package/dist/plugins/specweave-github/lib/index.js.map +1 -1
  66. package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts +7 -0
  67. package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts.map +1 -0
  68. package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.js +15 -0
  69. package/dist/plugins/specweave-testing/lib/playwright-ci-defaults.js.map +1 -0
  70. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts +10 -0
  71. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts.map +1 -0
  72. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js +36 -0
  73. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js.map +1 -0
  74. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts +25 -0
  75. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts.map +1 -0
  76. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js +57 -0
  77. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js.map +1 -0
  78. package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts +7 -0
  79. package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts.map +1 -0
  80. package/dist/plugins/specweave-testing/lib/playwright-routing.js +17 -0
  81. package/dist/plugins/specweave-testing/lib/playwright-routing.js.map +1 -0
  82. package/dist/src/cli/commands/auto.d.ts.map +1 -1
  83. package/dist/src/cli/commands/auto.js +1 -2
  84. package/dist/src/cli/commands/auto.js.map +1 -1
  85. package/dist/src/cli/commands/cancel-auto.js +1 -2
  86. package/dist/src/cli/commands/cancel-auto.js.map +1 -1
  87. package/dist/src/cli/commands/living-docs.js +2 -2
  88. package/dist/src/cli/commands/living-docs.js.map +1 -1
  89. package/dist/src/cli/commands/update.d.ts.map +1 -1
  90. package/dist/src/cli/commands/update.js +1 -2
  91. package/dist/src/cli/commands/update.js.map +1 -1
  92. package/dist/src/core/config/types.d.ts +8 -0
  93. package/dist/src/core/config/types.d.ts.map +1 -1
  94. package/dist/src/core/config/types.js +3 -0
  95. package/dist/src/core/config/types.js.map +1 -1
  96. package/dist/src/core/types/sync-profile.d.ts +72 -0
  97. package/dist/src/core/types/sync-profile.d.ts.map +1 -1
  98. package/dist/src/core/types/sync-profile.js +6 -0
  99. package/dist/src/core/types/sync-profile.js.map +1 -1
  100. package/package.json +2 -2
  101. package/plugins/specweave/hooks/hooks.json +2 -2
  102. package/plugins/specweave/hooks/startup-health-check.sh +1 -1
  103. package/plugins/specweave/hooks/stop-auto-v5.sh +166 -0
  104. package/plugins/specweave/hooks/user-prompt-submit.sh +10 -0
  105. package/plugins/specweave/hooks/v2/dispatchers/post-tool-use.sh +21 -1
  106. package/plugins/specweave/hooks/v2/dispatchers/session-start.sh +1 -1
  107. package/plugins/specweave/skills/auto/SKILL.md +71 -251
  108. package/plugins/specweave/skills/team-build/SKILL.md +370 -0
  109. package/plugins/specweave/skills/team-merge/SKILL.md +123 -0
  110. package/plugins/specweave/skills/team-orchestrate/SKILL.md +800 -0
  111. package/plugins/specweave/skills/team-status/SKILL.md +89 -0
  112. package/plugins/specweave-github/MULTI-PROJECT-SYNC-ARCHITECTURE.md +94 -8
  113. package/plugins/specweave-github/commands/sync.md +17 -3
  114. package/plugins/specweave-github/hooks/github-ac-sync-handler.sh +255 -0
  115. package/plugins/specweave-github/hooks/github-auto-create-handler.sh +455 -0
  116. package/plugins/specweave-github/lib/github-ac-comment-poster.js +150 -0
  117. package/plugins/specweave-github/lib/github-ac-comment-poster.ts +245 -0
  118. package/plugins/specweave-github/lib/github-batch-sync.js +93 -0
  119. package/plugins/specweave-github/lib/github-batch-sync.ts +152 -0
  120. package/plugins/specweave-github/lib/github-board-resolver-v2.js +47 -0
  121. package/plugins/specweave-github/lib/github-board-resolver-v2.ts +73 -0
  122. package/plugins/specweave-github/lib/github-conflict-resolver.js +90 -0
  123. package/plugins/specweave-github/lib/github-conflict-resolver.ts +154 -0
  124. package/plugins/specweave-github/lib/github-cross-repo-sync.js +168 -0
  125. package/plugins/specweave-github/lib/github-cross-repo-sync.ts +252 -0
  126. package/plugins/specweave-github/lib/github-field-sync.js +116 -0
  127. package/plugins/specweave-github/lib/github-field-sync.ts +165 -0
  128. package/plugins/specweave-github/lib/github-graphql-client.js +129 -0
  129. package/plugins/specweave-github/lib/github-graphql-client.ts +181 -0
  130. package/plugins/specweave-github/lib/github-issue-body-generator.js +30 -0
  131. package/plugins/specweave-github/lib/github-issue-body-generator.ts +76 -0
  132. package/plugins/specweave-github/lib/github-issue-body-parser.js +55 -0
  133. package/plugins/specweave-github/lib/github-issue-body-parser.ts +92 -0
  134. package/plugins/specweave-github/lib/github-pull-sync.js +185 -0
  135. package/plugins/specweave-github/lib/github-pull-sync.ts +343 -0
  136. package/plugins/specweave-github/lib/github-push-sync.js +119 -0
  137. package/plugins/specweave-github/lib/github-push-sync.ts +174 -0
  138. package/plugins/specweave-github/lib/github-rate-limiter.js +96 -0
  139. package/plugins/specweave-github/lib/github-rate-limiter.ts +143 -0
  140. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.js +117 -0
  141. package/plugins/specweave-github/lib/github-spec-frontmatter-updater.ts +180 -0
  142. package/plugins/specweave-github/lib/github-sync-orchestrator.js +84 -0
  143. package/plugins/specweave-github/lib/github-sync-orchestrator.ts +156 -0
  144. package/plugins/specweave-github/lib/github-us-auto-closer.js +134 -0
  145. package/plugins/specweave-github/lib/github-us-auto-closer.ts +226 -0
  146. package/plugins/specweave-github/lib/index.js +1 -7
  147. package/plugins/specweave-github/lib/index.ts +1 -4
  148. package/plugins/specweave-github/skills/github-sync/SKILL.md +76 -4
  149. package/plugins/specweave-testing/commands/e2e-setup.md +18 -0
  150. package/plugins/specweave-testing/commands/ui-automate.md +2 -0
  151. package/plugins/specweave-testing/commands/ui-inspect.md +8 -0
  152. package/plugins/specweave-testing/lib/playwright-ci-defaults.d.ts +6 -0
  153. package/plugins/specweave-testing/lib/playwright-ci-defaults.js +14 -0
  154. package/plugins/specweave-testing/lib/playwright-ci-defaults.ts +24 -0
  155. package/plugins/specweave-testing/lib/playwright-cli-detector.js +33 -0
  156. package/plugins/specweave-testing/lib/playwright-cli-detector.ts +48 -0
  157. package/plugins/specweave-testing/lib/playwright-cli-runner.js +58 -0
  158. package/plugins/specweave-testing/lib/playwright-cli-runner.ts +80 -0
  159. package/plugins/specweave-testing/lib/playwright-routing.js +16 -0
  160. package/plugins/specweave-testing/lib/playwright-routing.ts +38 -0
  161. package/plugins/specweave-testing/skills/e2e-testing/SKILL.md +38 -0
  162. package/src/templates/CLAUDE.md.template +7 -0
  163. package/src/templates/config.json.template +9 -1
  164. package/dist/plugins/specweave-github/lib/subtask-sync.d.ts +0 -51
  165. package/dist/plugins/specweave-github/lib/subtask-sync.d.ts.map +0 -1
  166. package/dist/plugins/specweave-github/lib/subtask-sync.js +0 -147
  167. package/dist/plugins/specweave-github/lib/subtask-sync.js.map +0 -1
  168. package/dist/plugins/specweave-github/lib/task-parser.d.ts +0 -37
  169. package/dist/plugins/specweave-github/lib/task-parser.d.ts.map +0 -1
  170. package/dist/plugins/specweave-github/lib/task-parser.js +0 -211
  171. package/dist/plugins/specweave-github/lib/task-parser.js.map +0 -1
  172. package/dist/plugins/specweave-github/lib/task-sync.d.ts +0 -56
  173. package/dist/plugins/specweave-github/lib/task-sync.d.ts.map +0 -1
  174. package/dist/plugins/specweave-github/lib/task-sync.js +0 -375
  175. package/dist/plugins/specweave-github/lib/task-sync.js.map +0 -1
  176. package/plugins/specweave/hooks/validate-completion-conditions.sh +0 -474
  177. package/plugins/specweave-github/lib/subtask-sync.d.ts +0 -51
  178. package/plugins/specweave-github/lib/subtask-sync.d.ts.map +0 -1
  179. package/plugins/specweave-github/lib/subtask-sync.js +0 -154
  180. package/plugins/specweave-github/lib/subtask-sync.js.map +0 -1
  181. package/plugins/specweave-github/lib/subtask-sync.ts +0 -225
  182. package/plugins/specweave-github/lib/task-parser.d.js +0 -0
  183. package/plugins/specweave-github/lib/task-parser.d.ts +0 -37
  184. package/plugins/specweave-github/lib/task-parser.d.ts.map +0 -1
  185. package/plugins/specweave-github/lib/task-parser.js +0 -195
  186. package/plugins/specweave-github/lib/task-parser.js.map +0 -1
  187. package/plugins/specweave-github/lib/task-parser.ts +0 -246
  188. package/plugins/specweave-github/lib/task-sync.d.js +0 -0
  189. package/plugins/specweave-github/lib/task-sync.d.ts +0 -51
  190. package/plugins/specweave-github/lib/task-sync.d.ts.map +0 -1
  191. package/plugins/specweave-github/lib/task-sync.js +0 -415
  192. package/plugins/specweave-github/lib/task-sync.js.map +0 -1
  193. package/plugins/specweave-github/lib/task-sync.ts +0 -451
  194. package/plugins/specweave-github/skills/github-issue-tracker/SKILL.md +0 -496
  195. /package/plugins/specweave/hooks/{stop-auto.sh → _archive/stop-auto-v4-legacy.sh} +0 -0
  196. /package/plugins/{specweave-github/lib/subtask-sync.d.js → specweave-testing/lib/playwright-ci-defaults.d.js} +0 -0
@@ -0,0 +1,119 @@
1
+ import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
2
+ import { generateIssueBody } from "./github-issue-body-generator.js";
3
+ async function pushSyncUserStories(userStories, options) {
4
+ const result = { created: [], updated: [], errors: [] };
5
+ if (options.dryRun) {
6
+ return result;
7
+ }
8
+ const env = getEnv(options.token);
9
+ const repoSlug = `${options.owner}/${options.repo}`;
10
+ for (const us of userStories) {
11
+ try {
12
+ const existing = await searchIssueByPrefix(us.id, repoSlug, env);
13
+ const body = generateIssueBody({
14
+ id: us.id,
15
+ title: us.title,
16
+ description: us.description,
17
+ priority: us.priority,
18
+ acceptanceCriteria: us.acceptanceCriteria,
19
+ specId: us.specId
20
+ });
21
+ const title = `[${us.id}] ${us.title}`;
22
+ if (existing) {
23
+ const updated = await updateIssue(existing.number, title, body, repoSlug, env);
24
+ result.updated.push({
25
+ userStoryId: us.id,
26
+ issueNumber: updated.number,
27
+ issueUrl: updated.url
28
+ });
29
+ } else {
30
+ const created = await createIssue(title, body, us, repoSlug, env);
31
+ result.created.push({
32
+ userStoryId: us.id,
33
+ issueNumber: created.number,
34
+ issueUrl: created.url,
35
+ issueNodeId: created.node_id
36
+ });
37
+ }
38
+ } catch (err) {
39
+ result.errors.push({
40
+ userStoryId: us.id,
41
+ error: err instanceof Error ? err.message : String(err)
42
+ });
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ async function searchIssueByPrefix(usId, repoSlug, env) {
48
+ const res = await execFileNoThrow("gh", [
49
+ "issue",
50
+ "list",
51
+ "--repo",
52
+ repoSlug,
53
+ "--search",
54
+ `[${usId}] in:title`,
55
+ "--json",
56
+ "number,title,node_id",
57
+ "--limit",
58
+ "1"
59
+ ], { env });
60
+ if (!res.success) {
61
+ throw new Error(`Search failed: ${res.stderr}`);
62
+ }
63
+ const issues = JSON.parse(res.stdout);
64
+ return issues.length > 0 ? issues[0] : null;
65
+ }
66
+ async function createIssue(title, body, us, repoSlug, env) {
67
+ const args = [
68
+ "issue",
69
+ "create",
70
+ "--repo",
71
+ repoSlug,
72
+ "--title",
73
+ title,
74
+ "--body",
75
+ body,
76
+ "--label",
77
+ "user-story",
78
+ "--label",
79
+ `spec:${us.specId || "unknown"}`,
80
+ "--label",
81
+ `priority:${us.priority}`,
82
+ "--json",
83
+ "number,url,node_id"
84
+ ];
85
+ const res = await execFileNoThrow("gh", args, { env });
86
+ if (!res.success) {
87
+ throw new Error(`Create failed: ${res.stderr}`);
88
+ }
89
+ return JSON.parse(res.stdout);
90
+ }
91
+ async function updateIssue(issueNumber, title, body, repoSlug, env) {
92
+ const args = [
93
+ "issue",
94
+ "edit",
95
+ String(issueNumber),
96
+ "--repo",
97
+ repoSlug,
98
+ "--title",
99
+ title,
100
+ "--body",
101
+ body,
102
+ "--json",
103
+ "number,url"
104
+ ];
105
+ const res = await execFileNoThrow("gh", args, { env });
106
+ if (!res.success) {
107
+ throw new Error(`Update failed: ${res.stderr}`);
108
+ }
109
+ return JSON.parse(res.stdout);
110
+ }
111
+ function getEnv(token) {
112
+ if (token) {
113
+ return { ...process.env, GH_TOKEN: token };
114
+ }
115
+ return process.env;
116
+ }
117
+ export {
118
+ pushSyncUserStories
119
+ };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * GitHub Push Sync — User Stories → GitHub Issues
3
+ *
4
+ * Creates or updates GitHub issues from SpecWeave user stories.
5
+ * Uses `gh` CLI for all operations (no direct HTTP).
6
+ *
7
+ * @module github-push-sync
8
+ */
9
+
10
+ import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
11
+ import { generateIssueBody } from './github-issue-body-generator.js';
12
+
13
+ export interface UserStoryForSync {
14
+ id: string;
15
+ title: string;
16
+ description: string;
17
+ priority: string;
18
+ status: string;
19
+ acceptanceCriteria: Array<{ id: string; description: string; completed: boolean }>;
20
+ specId?: string;
21
+ }
22
+
23
+ export interface PushSyncOptions {
24
+ owner: string;
25
+ repo: string;
26
+ token?: string;
27
+ dryRun?: boolean;
28
+ }
29
+
30
+ export interface PushSyncResult {
31
+ created: Array<{ userStoryId: string; issueNumber: number; issueUrl: string; issueNodeId: string }>;
32
+ updated: Array<{ userStoryId: string; issueNumber: number; issueUrl: string }>;
33
+ errors: Array<{ userStoryId: string; error: string }>;
34
+ }
35
+
36
+ /**
37
+ * Push sync user stories to GitHub issues.
38
+ * For each US: search by [US-XXX] prefix, create if new, update if existing.
39
+ */
40
+ export async function pushSyncUserStories(
41
+ userStories: UserStoryForSync[],
42
+ options: PushSyncOptions,
43
+ ): Promise<PushSyncResult> {
44
+ const result: PushSyncResult = { created: [], updated: [], errors: [] };
45
+
46
+ if (options.dryRun) {
47
+ return result;
48
+ }
49
+
50
+ const env = getEnv(options.token);
51
+ const repoSlug = `${options.owner}/${options.repo}`;
52
+
53
+ for (const us of userStories) {
54
+ try {
55
+ const existing = await searchIssueByPrefix(us.id, repoSlug, env);
56
+
57
+ const body = generateIssueBody({
58
+ id: us.id,
59
+ title: us.title,
60
+ description: us.description,
61
+ priority: us.priority,
62
+ acceptanceCriteria: us.acceptanceCriteria,
63
+ specId: us.specId,
64
+ });
65
+
66
+ const title = `[${us.id}] ${us.title}`;
67
+
68
+ if (existing) {
69
+ // Update existing issue
70
+ const updated = await updateIssue(existing.number, title, body, repoSlug, env);
71
+ result.updated.push({
72
+ userStoryId: us.id,
73
+ issueNumber: updated.number,
74
+ issueUrl: updated.url,
75
+ });
76
+ } else {
77
+ // Create new issue
78
+ const created = await createIssue(title, body, us, repoSlug, env);
79
+ result.created.push({
80
+ userStoryId: us.id,
81
+ issueNumber: created.number,
82
+ issueUrl: created.url,
83
+ issueNodeId: created.node_id,
84
+ });
85
+ }
86
+ } catch (err) {
87
+ result.errors.push({
88
+ userStoryId: us.id,
89
+ error: err instanceof Error ? err.message : String(err),
90
+ });
91
+ }
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ async function searchIssueByPrefix(
98
+ usId: string,
99
+ repoSlug: string,
100
+ env: NodeJS.ProcessEnv,
101
+ ): Promise<{ number: number; title: string; node_id: string } | null> {
102
+ const res = await execFileNoThrow('gh', [
103
+ 'issue', 'list',
104
+ '--repo', repoSlug,
105
+ '--search', `[${usId}] in:title`,
106
+ '--json', 'number,title,node_id',
107
+ '--limit', '1',
108
+ ], { env });
109
+
110
+ if (!res.success) {
111
+ throw new Error(`Search failed: ${res.stderr}`);
112
+ }
113
+
114
+ const issues = JSON.parse(res.stdout);
115
+ return issues.length > 0 ? issues[0] : null;
116
+ }
117
+
118
+ async function createIssue(
119
+ title: string,
120
+ body: string,
121
+ us: UserStoryForSync,
122
+ repoSlug: string,
123
+ env: NodeJS.ProcessEnv,
124
+ ): Promise<{ number: number; url: string; node_id: string }> {
125
+ const args = [
126
+ 'issue', 'create',
127
+ '--repo', repoSlug,
128
+ '--title', title,
129
+ '--body', body,
130
+ '--label', 'user-story',
131
+ '--label', `spec:${us.specId || 'unknown'}`,
132
+ '--label', `priority:${us.priority}`,
133
+ '--json', 'number,url,node_id',
134
+ ];
135
+
136
+ const res = await execFileNoThrow('gh', args, { env });
137
+
138
+ if (!res.success) {
139
+ throw new Error(`Create failed: ${res.stderr}`);
140
+ }
141
+
142
+ return JSON.parse(res.stdout);
143
+ }
144
+
145
+ async function updateIssue(
146
+ issueNumber: number,
147
+ title: string,
148
+ body: string,
149
+ repoSlug: string,
150
+ env: NodeJS.ProcessEnv,
151
+ ): Promise<{ number: number; url: string }> {
152
+ const args = [
153
+ 'issue', 'edit', String(issueNumber),
154
+ '--repo', repoSlug,
155
+ '--title', title,
156
+ '--body', body,
157
+ '--json', 'number,url',
158
+ ];
159
+
160
+ const res = await execFileNoThrow('gh', args, { env });
161
+
162
+ if (!res.success) {
163
+ throw new Error(`Update failed: ${res.stderr}`);
164
+ }
165
+
166
+ return JSON.parse(res.stdout);
167
+ }
168
+
169
+ function getEnv(token?: string): NodeJS.ProcessEnv {
170
+ if (token) {
171
+ return { ...process.env, GH_TOKEN: token };
172
+ }
173
+ return process.env;
174
+ }
@@ -0,0 +1,96 @@
1
+ import { execFileNoThrow } from "../../../src/utils/execFileNoThrow.js";
2
+ const CALLS_PER_USER_STORY = 3;
3
+ const CALLS_PER_SPEC_OVERHEAD = 2;
4
+ class GitHubRateLimiter {
5
+ constructor(token) {
6
+ this.totalUsed = 0;
7
+ this.knownLimit = 0;
8
+ this.token = token;
9
+ }
10
+ /**
11
+ * Query current GitHub API rate limit via `gh api rate_limit`.
12
+ */
13
+ async checkRateLimit() {
14
+ const env = this.getEnv();
15
+ const result = await execFileNoThrow("gh", ["api", "rate_limit"], { env });
16
+ if (!result.success) {
17
+ throw new Error(`gh CLI failed: ${result.stderr || result.error?.message || "unknown error"}`);
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(result.stdout);
22
+ } catch {
23
+ throw new Error(`Failed to parse rate limit response: ${result.stdout.slice(0, 100)}`);
24
+ }
25
+ const core = parsed?.resources?.core;
26
+ if (!core || core.remaining === void 0 || core.limit === void 0 || core.reset === void 0) {
27
+ throw new Error("Rate limit response missing expected fields (resources.core)");
28
+ }
29
+ this.knownLimit = core.limit;
30
+ const percentUsed = core.limit > 0 ? (core.limit - core.remaining) / core.limit * 100 : 0;
31
+ return {
32
+ remaining: core.remaining,
33
+ limit: core.limit,
34
+ resetAt: new Date(core.reset * 1e3),
35
+ percentUsed
36
+ };
37
+ }
38
+ /**
39
+ * Estimate how many API calls a sync operation will require.
40
+ */
41
+ estimateApiCalls(specCount, userStoryCount) {
42
+ if (specCount === 0 && userStoryCount === 0) {
43
+ return 0;
44
+ }
45
+ return specCount * CALLS_PER_SPEC_OVERHEAD + userStoryCount * CALLS_PER_USER_STORY;
46
+ }
47
+ /**
48
+ * Check whether a sync operation can proceed given the current rate limit.
49
+ */
50
+ async canProceed(estimatedCalls) {
51
+ const status = await this.checkRateLimit();
52
+ if (estimatedCalls > status.remaining) {
53
+ const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
54
+ return {
55
+ allowed: false,
56
+ waitMs,
57
+ reason: `Rate limit: need ${estimatedCalls} calls but only ${status.remaining}/${status.limit} remaining. Resets in ${Math.ceil(waitMs / 1e3)}s`
58
+ };
59
+ }
60
+ if (status.percentUsed > 90) {
61
+ const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
62
+ return {
63
+ allowed: false,
64
+ waitMs,
65
+ reason: `Rate limit at ${status.percentUsed.toFixed(0)}% \u2014 too high to proceed safely`
66
+ };
67
+ }
68
+ return { allowed: true };
69
+ }
70
+ /**
71
+ * Record API calls made during a sync operation.
72
+ */
73
+ recordUsage(calls) {
74
+ if (calls < 0) {
75
+ throw new Error("Cannot record negative usage");
76
+ }
77
+ this.totalUsed += calls;
78
+ }
79
+ /**
80
+ * Get the percentage of the known limit used by this limiter instance.
81
+ */
82
+ getUsagePercent() {
83
+ if (this.knownLimit === 0) return 0;
84
+ const pct = this.totalUsed / this.knownLimit * 100;
85
+ return Math.min(pct, 100);
86
+ }
87
+ getEnv() {
88
+ if (this.token) {
89
+ return { ...process.env, GH_TOKEN: this.token };
90
+ }
91
+ return process.env;
92
+ }
93
+ }
94
+ export {
95
+ GitHubRateLimiter
96
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * GitHub Rate Limiter
3
+ *
4
+ * Shared token-bucket rate limiter for multi-repo GitHub sync.
5
+ * Queries `gh api rate_limit`, tracks cumulative usage, and gates
6
+ * sync operations when approaching API limits.
7
+ *
8
+ * @module github-rate-limiter
9
+ */
10
+
11
+ import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
12
+
13
+ export interface RateLimitStatus {
14
+ /** Remaining requests in current window */
15
+ remaining: number;
16
+ /** Total limit per window */
17
+ limit: number;
18
+ /** When the rate limit resets */
19
+ resetAt: Date;
20
+ /** Percentage of limit used (0-100) */
21
+ percentUsed: number;
22
+ }
23
+
24
+ interface CanProceedResult {
25
+ allowed: boolean;
26
+ waitMs?: number;
27
+ reason?: string;
28
+ }
29
+
30
+ /** Calls per user story: search + create/update + labels */
31
+ const CALLS_PER_USER_STORY = 3;
32
+ /** Overhead per spec: milestone check, project check */
33
+ const CALLS_PER_SPEC_OVERHEAD = 2;
34
+
35
+ export class GitHubRateLimiter {
36
+ private token?: string;
37
+ private totalUsed = 0;
38
+ private knownLimit = 0;
39
+
40
+ constructor(token?: string) {
41
+ this.token = token;
42
+ }
43
+
44
+ /**
45
+ * Query current GitHub API rate limit via `gh api rate_limit`.
46
+ */
47
+ async checkRateLimit(): Promise<RateLimitStatus> {
48
+ const env = this.getEnv();
49
+ const result = await execFileNoThrow('gh', ['api', 'rate_limit'], { env });
50
+
51
+ if (!result.success) {
52
+ throw new Error(`gh CLI failed: ${result.stderr || result.error?.message || 'unknown error'}`);
53
+ }
54
+
55
+ let parsed: { resources?: { core?: { remaining?: number; limit?: number; reset?: number } } };
56
+ try {
57
+ parsed = JSON.parse(result.stdout);
58
+ } catch {
59
+ throw new Error(`Failed to parse rate limit response: ${result.stdout.slice(0, 100)}`);
60
+ }
61
+
62
+ const core = parsed?.resources?.core;
63
+ if (!core || core.remaining === undefined || core.limit === undefined || core.reset === undefined) {
64
+ throw new Error('Rate limit response missing expected fields (resources.core)');
65
+ }
66
+
67
+ this.knownLimit = core.limit;
68
+
69
+ const percentUsed = core.limit > 0
70
+ ? ((core.limit - core.remaining) / core.limit) * 100
71
+ : 0;
72
+
73
+ return {
74
+ remaining: core.remaining,
75
+ limit: core.limit,
76
+ resetAt: new Date(core.reset * 1000),
77
+ percentUsed,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Estimate how many API calls a sync operation will require.
83
+ */
84
+ estimateApiCalls(specCount: number, userStoryCount: number): number {
85
+ if (specCount === 0 && userStoryCount === 0) {
86
+ return 0;
87
+ }
88
+ return (specCount * CALLS_PER_SPEC_OVERHEAD) + (userStoryCount * CALLS_PER_USER_STORY);
89
+ }
90
+
91
+ /**
92
+ * Check whether a sync operation can proceed given the current rate limit.
93
+ */
94
+ async canProceed(estimatedCalls: number): Promise<CanProceedResult> {
95
+ const status = await this.checkRateLimit();
96
+
97
+ if (estimatedCalls > status.remaining) {
98
+ const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
99
+ return {
100
+ allowed: false,
101
+ waitMs,
102
+ reason: `Rate limit: need ${estimatedCalls} calls but only ${status.remaining}/${status.limit} remaining. Resets in ${Math.ceil(waitMs / 1000)}s`,
103
+ };
104
+ }
105
+
106
+ if (status.percentUsed > 90) {
107
+ const waitMs = Math.max(0, status.resetAt.getTime() - Date.now());
108
+ return {
109
+ allowed: false,
110
+ waitMs,
111
+ reason: `Rate limit at ${status.percentUsed.toFixed(0)}% — too high to proceed safely`,
112
+ };
113
+ }
114
+
115
+ return { allowed: true };
116
+ }
117
+
118
+ /**
119
+ * Record API calls made during a sync operation.
120
+ */
121
+ recordUsage(calls: number): void {
122
+ if (calls < 0) {
123
+ throw new Error('Cannot record negative usage');
124
+ }
125
+ this.totalUsed += calls;
126
+ }
127
+
128
+ /**
129
+ * Get the percentage of the known limit used by this limiter instance.
130
+ */
131
+ getUsagePercent(): number {
132
+ if (this.knownLimit === 0) return 0;
133
+ const pct = (this.totalUsed / this.knownLimit) * 100;
134
+ return Math.min(pct, 100);
135
+ }
136
+
137
+ private getEnv(): NodeJS.ProcessEnv {
138
+ if (this.token) {
139
+ return { ...process.env, GH_TOKEN: this.token };
140
+ }
141
+ return process.env;
142
+ }
143
+ }
@@ -0,0 +1,117 @@
1
+ import { readFile, writeFile } from "fs/promises";
2
+ async function updateSpecFrontmatter(specPath, syncResult, options) {
3
+ const content = await readFile(specPath, "utf-8");
4
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
5
+ let frontmatter = {};
6
+ let body = content;
7
+ if (fmMatch) {
8
+ frontmatter = parseYamlSimple(fmMatch[1]);
9
+ body = content.slice(fmMatch[0].length);
10
+ }
11
+ const externalLinks = frontmatter.externalLinks ?? {};
12
+ const existingGithub = externalLinks.github ?? {};
13
+ const existingUserStories = existingGithub.userStories ?? {};
14
+ const userStories = { ...existingUserStories };
15
+ const now = (/* @__PURE__ */ new Date()).toISOString();
16
+ for (const item of syncResult.created) {
17
+ userStories[item.userStoryId] = {
18
+ issueNumber: item.issueNumber,
19
+ issueUrl: item.issueUrl,
20
+ issueNodeId: item.issueNodeId,
21
+ syncedAt: now
22
+ };
23
+ }
24
+ for (const item of syncResult.updated) {
25
+ const existing = userStories[item.userStoryId];
26
+ userStories[item.userStoryId] = {
27
+ issueNumber: item.issueNumber,
28
+ issueUrl: item.issueUrl,
29
+ issueNodeId: existing?.issueNodeId,
30
+ syncedAt: now
31
+ };
32
+ }
33
+ const syncStatus = syncResult.errors.length > 0 ? "dirty" : "synced";
34
+ const metadata = {
35
+ syncStatus,
36
+ userStories
37
+ };
38
+ if (options?.projectV2Id) {
39
+ metadata.projectV2Id = options.projectV2Id;
40
+ } else if (existingGithub.projectV2Id) {
41
+ metadata.projectV2Id = existingGithub.projectV2Id;
42
+ }
43
+ if (options?.projectV2Number) {
44
+ metadata.projectV2Number = options.projectV2Number;
45
+ } else if (existingGithub.projectV2Number) {
46
+ metadata.projectV2Number = existingGithub.projectV2Number;
47
+ }
48
+ externalLinks.github = metadata;
49
+ frontmatter.externalLinks = externalLinks;
50
+ const newFrontmatter = stringifyYaml(frontmatter);
51
+ const newContent = `---
52
+ ${newFrontmatter}
53
+ ---${body}`;
54
+ await writeFile(specPath, newContent, "utf-8");
55
+ return metadata;
56
+ }
57
+ function parseYamlSimple(yaml) {
58
+ const result = {};
59
+ const lines = yaml.split("\n");
60
+ const stack = [
61
+ { obj: result, indent: -1 }
62
+ ];
63
+ for (const line of lines) {
64
+ if (!line.trim() || line.trim().startsWith("#")) continue;
65
+ const indent = line.search(/\S/);
66
+ const trimmed = line.trim();
67
+ const colonIdx = trimmed.indexOf(":");
68
+ if (colonIdx === -1) continue;
69
+ const key = trimmed.slice(0, colonIdx).trim();
70
+ const rawValue = trimmed.slice(colonIdx + 1).trim();
71
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
72
+ stack.pop();
73
+ }
74
+ const parent = stack[stack.length - 1].obj;
75
+ if (rawValue === "" || rawValue === void 0) {
76
+ const child = {};
77
+ parent[key] = child;
78
+ stack.push({ obj: child, indent });
79
+ } else {
80
+ parent[key] = parseYamlValue(rawValue);
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+ function parseYamlValue(raw) {
86
+ if (raw === "null") return null;
87
+ if (raw === "true") return true;
88
+ if (raw === "false") return false;
89
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
90
+ if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
91
+ if (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'")) {
92
+ return raw.slice(1, -1);
93
+ }
94
+ return raw;
95
+ }
96
+ function stringifyYaml(obj, indent = 0) {
97
+ const prefix = " ".repeat(indent);
98
+ const parts = [];
99
+ for (const [key, value] of Object.entries(obj)) {
100
+ if (value === null || value === void 0) {
101
+ parts.push(`${prefix}${key}: null`);
102
+ } else if (typeof value === "object" && !Array.isArray(value)) {
103
+ parts.push(`${prefix}${key}:`);
104
+ parts.push(stringifyYaml(value, indent + 1));
105
+ } else if (typeof value === "string") {
106
+ parts.push(`${prefix}${key}: "${value}"`);
107
+ } else if (typeof value === "boolean" || typeof value === "number") {
108
+ parts.push(`${prefix}${key}: ${value}`);
109
+ } else {
110
+ parts.push(`${prefix}${key}: ${JSON.stringify(value)}`);
111
+ }
112
+ }
113
+ return parts.join("\n");
114
+ }
115
+ export {
116
+ updateSpecFrontmatter
117
+ };