agile-context-engineering 0.2.2 → 0.5.0

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 (146) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +27 -18
  4. package/agents/ace-product-owner.md +1 -1
  5. package/agents/ace-technical-application-architect.md +28 -0
  6. package/agents/ace-wiki-mapper.md +144 -29
  7. package/bin/install.js +67 -63
  8. package/hooks/ace-check-update.js +17 -9
  9. package/package.json +7 -5
  10. package/shared/lib/ace-core.js +308 -0
  11. package/shared/lib/ace-core.test.js +308 -0
  12. package/shared/lib/ace-github.js +753 -0
  13. package/shared/lib/ace-story.js +400 -0
  14. package/shared/lib/ace-story.test.js +250 -0
  15. package/{agile-context-engineering → shared}/utils/ui-formatting.md +299 -299
  16. package/skills/execute-story/SKILL.md +110 -0
  17. package/skills/execute-story/script.js +305 -0
  18. package/skills/execute-story/script.test.js +261 -0
  19. package/skills/execute-story/walkthrough-template.xml +255 -0
  20. package/{agile-context-engineering/workflows/execute-story.xml → skills/execute-story/workflow.xml} +83 -9
  21. package/skills/help/SKILL.md +69 -0
  22. package/skills/help/script.js +318 -0
  23. package/skills/help/script.test.js +183 -0
  24. package/{agile-context-engineering/workflows/help.xml → skills/help/workflow.xml} +8 -8
  25. package/skills/init-coding-standards/SKILL.md +72 -0
  26. package/{agile-context-engineering/templates/wiki/coding-standards.xml → skills/init-coding-standards/coding-standards-template.xml} +38 -0
  27. package/skills/init-coding-standards/script.js +59 -0
  28. package/skills/init-coding-standards/script.test.js +70 -0
  29. package/{agile-context-engineering/workflows/init-coding-standards.xml → skills/init-coding-standards/workflow.xml} +4 -9
  30. package/skills/map-cross-cutting/SKILL.md +89 -0
  31. package/skills/map-cross-cutting/workflow.xml +330 -0
  32. package/skills/map-guide/SKILL.md +89 -0
  33. package/skills/map-guide/workflow.xml +320 -0
  34. package/skills/map-pattern/SKILL.md +89 -0
  35. package/skills/map-pattern/workflow.xml +331 -0
  36. package/skills/map-story/SKILL.md +127 -0
  37. package/skills/map-story/templates/guide.xml +137 -0
  38. package/skills/map-story/templates/pattern.xml +159 -0
  39. package/skills/map-story/templates/system-cross-cutting.xml +197 -0
  40. package/skills/map-story/templates/walkthrough.xml +255 -0
  41. package/{agile-context-engineering/workflows/map-story.xml → skills/map-story/workflow.xml} +258 -9
  42. package/skills/map-subsystem/SKILL.md +111 -0
  43. package/skills/map-subsystem/script.js +60 -0
  44. package/skills/map-subsystem/script.test.js +68 -0
  45. package/skills/map-subsystem/templates/decizions.xml +115 -0
  46. package/skills/map-subsystem/templates/guide.xml +137 -0
  47. package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/module-discovery.xml +3 -3
  48. package/skills/map-subsystem/templates/pattern.xml +159 -0
  49. package/skills/map-subsystem/templates/system-cross-cutting.xml +197 -0
  50. package/skills/map-subsystem/templates/system.xml +381 -0
  51. package/skills/map-subsystem/templates/walkthrough.xml +255 -0
  52. package/{agile-context-engineering/workflows/map-subsystem.xml → skills/map-subsystem/workflow.xml} +17 -21
  53. package/skills/map-sys-doc/SKILL.md +90 -0
  54. package/skills/map-sys-doc/system.xml +381 -0
  55. package/skills/map-sys-doc/workflow.xml +336 -0
  56. package/skills/map-system/SKILL.md +85 -0
  57. package/skills/map-system/script.js +84 -0
  58. package/skills/map-system/script.test.js +73 -0
  59. package/skills/map-system/templates/wiki-readme.xml +297 -0
  60. package/{agile-context-engineering/workflows/map-system.xml → skills/map-system/workflow.xml} +11 -16
  61. package/skills/map-walkthrough/SKILL.md +92 -0
  62. package/skills/map-walkthrough/walkthrough.xml +255 -0
  63. package/skills/map-walkthrough/workflow.xml +457 -0
  64. package/skills/plan-backlog/SKILL.md +75 -0
  65. package/skills/plan-backlog/script.js +136 -0
  66. package/skills/plan-backlog/script.test.js +83 -0
  67. package/{agile-context-engineering/workflows/plan-backlog.xml → skills/plan-backlog/workflow.xml} +13 -21
  68. package/skills/plan-feature/SKILL.md +76 -0
  69. package/skills/plan-feature/script.js +148 -0
  70. package/skills/plan-feature/script.test.js +80 -0
  71. package/{agile-context-engineering/workflows/plan-feature.xml → skills/plan-feature/workflow.xml} +21 -29
  72. package/skills/plan-product-vision/SKILL.md +75 -0
  73. package/skills/plan-product-vision/script.js +60 -0
  74. package/skills/plan-product-vision/script.test.js +69 -0
  75. package/{agile-context-engineering/workflows/plan-product-vision.xml → skills/plan-product-vision/workflow.xml} +4 -9
  76. package/skills/plan-story/SKILL.md +116 -0
  77. package/skills/plan-story/script.js +326 -0
  78. package/skills/plan-story/script.test.js +240 -0
  79. package/skills/plan-story/story-template.xml +451 -0
  80. package/{agile-context-engineering/workflows/plan-story.xml → skills/plan-story/workflow.xml} +1285 -909
  81. package/skills/research-external-solution/SKILL.md +107 -0
  82. package/skills/research-external-solution/script.js +238 -0
  83. package/skills/research-external-solution/script.test.js +134 -0
  84. package/{agile-context-engineering/workflows/research-external-solution.xml → skills/research-external-solution/workflow.xml} +4 -6
  85. package/skills/research-integration-solution/SKILL.md +98 -0
  86. package/{agile-context-engineering/templates/product/story-integration-solution.xml → skills/research-integration-solution/integration-solution-template.xml} +1 -0
  87. package/skills/research-integration-solution/script.js +231 -0
  88. package/skills/research-integration-solution/script.test.js +134 -0
  89. package/{agile-context-engineering/workflows/research-integration-solution.xml → skills/research-integration-solution/workflow.xml} +4 -5
  90. package/skills/research-story-wiki/SKILL.md +92 -0
  91. package/skills/research-story-wiki/script.js +231 -0
  92. package/skills/research-story-wiki/script.test.js +138 -0
  93. package/{agile-context-engineering/templates/product/story-wiki.xml → skills/research-story-wiki/story-wiki-template.xml} +4 -0
  94. package/{agile-context-engineering/workflows/research-story-wiki.xml → skills/research-story-wiki/workflow.xml} +5 -6
  95. package/skills/research-technical-solution/SKILL.md +103 -0
  96. package/skills/research-technical-solution/script.js +231 -0
  97. package/skills/research-technical-solution/script.test.js +134 -0
  98. package/{agile-context-engineering/workflows/research-technical-solution.xml → skills/research-technical-solution/workflow.xml} +4 -5
  99. package/skills/review-story/SKILL.md +100 -0
  100. package/skills/review-story/script.js +257 -0
  101. package/skills/review-story/script.test.js +169 -0
  102. package/skills/review-story/story-template.xml +451 -0
  103. package/{agile-context-engineering/workflows/review-story.xml → skills/review-story/workflow.xml} +1 -3
  104. package/skills/update/SKILL.md +53 -0
  105. package/{agile-context-engineering/workflows/update.xml → skills/update/workflow.xml} +237 -207
  106. package/agile-context-engineering/src/ace-tools.js +0 -2881
  107. package/agile-context-engineering/src/ace-tools.test.js +0 -1089
  108. package/agile-context-engineering/templates/_command.md +0 -54
  109. package/agile-context-engineering/templates/_workflow.xml +0 -17
  110. package/agile-context-engineering/templates/config.json +0 -0
  111. package/agile-context-engineering/templates/product/integration-solution.xml +0 -0
  112. package/agile-context-engineering/templates/wiki/wiki-readme.xml +0 -276
  113. package/commands/ace/execute-story.md +0 -137
  114. package/commands/ace/help.md +0 -93
  115. package/commands/ace/init-coding-standards.md +0 -83
  116. package/commands/ace/map-story.md +0 -156
  117. package/commands/ace/map-subsystem.md +0 -138
  118. package/commands/ace/map-system.md +0 -92
  119. package/commands/ace/plan-backlog.md +0 -83
  120. package/commands/ace/plan-feature.md +0 -89
  121. package/commands/ace/plan-product-vision.md +0 -81
  122. package/commands/ace/plan-story.md +0 -145
  123. package/commands/ace/research-external-solution.md +0 -138
  124. package/commands/ace/research-integration-solution.md +0 -135
  125. package/commands/ace/research-story-wiki.md +0 -116
  126. package/commands/ace/research-technical-solution.md +0 -147
  127. package/commands/ace/review-story.md +0 -109
  128. package/commands/ace/update.md +0 -54
  129. /package/{agile-context-engineering → shared}/utils/questioning.xml +0 -0
  130. /package/{agile-context-engineering/templates/product/story.xml → skills/execute-story/story-template.xml} +0 -0
  131. /package/{agile-context-engineering/templates/wiki → skills/map-cross-cutting}/system-cross-cutting.xml +0 -0
  132. /package/{agile-context-engineering/templates/wiki → skills/map-guide}/guide.xml +0 -0
  133. /package/{agile-context-engineering/templates/wiki → skills/map-pattern}/pattern.xml +0 -0
  134. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/decizions.xml +0 -0
  135. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/system.xml +0 -0
  136. /package/{agile-context-engineering/templates/wiki → skills/map-story/templates}/tech-debt-index.xml +0 -0
  137. /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-architecture.xml +0 -0
  138. /package/{agile-context-engineering/templates/wiki → skills/map-subsystem/templates}/subsystem-structure.xml +0 -0
  139. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-architecture.xml +0 -0
  140. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/system-structure.xml +0 -0
  141. /package/{agile-context-engineering/templates/wiki → skills/map-system/templates}/testing-framework.xml +0 -0
  142. /package/{agile-context-engineering/templates/product/product-backlog.xml → skills/plan-backlog/product-backlog-template.xml} +0 -0
  143. /package/{agile-context-engineering/templates/product/feature.xml → skills/plan-feature/feature-template.xml} +0 -0
  144. /package/{agile-context-engineering/templates/product/product-vision.xml → skills/plan-product-vision/product-vision-template.xml} +0 -0
  145. /package/{agile-context-engineering/templates/product/external-solution.xml → skills/research-external-solution/external-solution-template.xml} +0 -0
  146. /package/{agile-context-engineering/templates/product/story-technical-solution.xml → skills/research-technical-solution/technical-solution-template.xml} +0 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * ACE GitHub — GitHub integration operations shared across ACE skills.
3
+ *
4
+ * Extracted from ace-tools.js monolith. Contains: project context resolution
5
+ * and story/feature GitHub sync.
6
+ *
7
+ * Usage: const { syncStory, resolveProjectContext } = require('./ace-github');
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+ const {
15
+ safeReadFile, parseKeyValueArgs, execCommand, output, error,
16
+ } = require('./ace-core');
17
+ const {
18
+ extractStoryMetadata, extractIssueNumberFromFile,
19
+ } = require('./ace-story');
20
+
21
+ // ─── Project Context ─────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Resolve project ID and field definitions for a GitHub Project.
25
+ * Returns { project_id, fields } where fields maps field names to { id, type, options? }.
26
+ */
27
+ function resolveProjectContext(owner, project, cwd) {
28
+ const projectListRaw = execCommand(
29
+ `gh project list --owner ${owner} --format json --limit 20`,
30
+ cwd
31
+ );
32
+
33
+ let project_id = null;
34
+ if (projectListRaw) {
35
+ try {
36
+ const parsed = JSON.parse(projectListRaw);
37
+ const projects = parsed.projects || parsed || [];
38
+ const match = projects.find(p => String(p.number) === String(project));
39
+ if (match) project_id = match.id;
40
+ } catch {}
41
+ }
42
+
43
+ const fieldsRaw = execCommand(
44
+ `gh project field-list ${project} --owner ${owner} --format json`,
45
+ cwd
46
+ );
47
+
48
+ const fields = {};
49
+ if (fieldsRaw) {
50
+ try {
51
+ const parsed = JSON.parse(fieldsRaw);
52
+ const fieldList = parsed.fields || parsed || [];
53
+ for (const field of fieldList) {
54
+ const entry = { id: field.id, type: field.type };
55
+ if (field.options) {
56
+ entry.options = {};
57
+ for (const opt of field.options) {
58
+ entry.options[opt.name] = opt.id;
59
+ }
60
+ }
61
+ fields[field.name] = entry;
62
+ }
63
+ } catch {}
64
+ }
65
+
66
+ return { project_id, fields };
67
+ }
68
+
69
+ // ─── Story Sync ──────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Sync story and feature body + project status to GitHub.
73
+ * Updates issue body via --body-file and project status via GraphQL.
74
+ *
75
+ * Required args: repo=owner/name story_file=path
76
+ * Optional args: feature_file=path owner=org project=number
77
+ */
78
+ function syncStory(cwd, raw, extraArgs) {
79
+ const params = parseKeyValueArgs(extraArgs);
80
+ const repo = params.repo;
81
+ const storyFile = params.story_file;
82
+
83
+ if (!repo || !storyFile) {
84
+ error('sync-github requires: repo=owner/name story_file=path');
85
+ }
86
+
87
+ const result = {
88
+ story: { number: null, updated: false, status_synced: false, error: null },
89
+ feature: { number: null, updated: false, status_synced: false, error: null },
90
+ };
91
+
92
+ // Resolve project context for status updates (optional)
93
+ const owner = params.owner;
94
+ const project = params.project;
95
+ let projectCtx = null;
96
+
97
+ if (owner && project) {
98
+ projectCtx = resolveProjectContext(owner, project, cwd);
99
+ if (!projectCtx.project_id) {
100
+ process.stderr.write(` ! Could not resolve GitHub Project #${project}. Status updates skipped.\n`);
101
+ projectCtx = null;
102
+ } else if (!projectCtx.fields.Status) {
103
+ process.stderr.write(' ! GitHub Project has no Status field. Status updates skipped.\n');
104
+ projectCtx = null;
105
+ }
106
+ }
107
+
108
+ // Helper: update project status for a single issue
109
+ function syncProjectStatus(issueNumber, filePath, label) {
110
+ if (!projectCtx) return false;
111
+
112
+ const content = safeReadFile(filePath);
113
+ if (!content) return false;
114
+
115
+ const metadata = extractStoryMetadata(content);
116
+ const localStatus = metadata.status;
117
+ if (!localStatus) {
118
+ process.stderr.write(` — ${label} has no Status field. Skipping project status update.\n`);
119
+ return false;
120
+ }
121
+
122
+ const statusField = projectCtx.fields.Status;
123
+ const statusOptionId = statusField.options?.[localStatus];
124
+ if (!statusOptionId) {
125
+ process.stderr.write(` ! GitHub Project has no status option "${localStatus}". Skipping status update for ${label}.\n`);
126
+ return false;
127
+ }
128
+
129
+ // Look up project item ID via GraphQL
130
+ const repoParts = repo.split('/');
131
+ const repoOwner = repoParts[0];
132
+ const repoName = repoParts[1] || repoParts[0];
133
+ const itemQuery = `query { repository(owner: \\"${repoOwner}\\", name: \\"${repoName}\\") { issue(number: ${issueNumber}) { projectItems(first: 10) { nodes { id project { id } } } } } }`;
134
+ const itemResult = execCommand(
135
+ `gh api graphql -f query="${itemQuery}"`,
136
+ cwd
137
+ );
138
+ let itemId = null;
139
+ if (itemResult) {
140
+ try {
141
+ const parsed = JSON.parse(itemResult);
142
+ const nodes = parsed.data?.repository?.issue?.projectItems?.nodes || [];
143
+ const match = nodes.find(n => n.project?.id === projectCtx.project_id);
144
+ itemId = match?.id || null;
145
+ } catch {}
146
+ }
147
+ if (!itemId) {
148
+ process.stderr.write(` ! ${label} #${issueNumber} not found in GitHub Project. Skipping status update.\n`);
149
+ return false;
150
+ }
151
+
152
+ const statusOk = execCommand(
153
+ `gh project item-edit --project-id ${projectCtx.project_id} --id ${itemId} --field-id ${statusField.id} --single-select-option-id ${statusOptionId}`,
154
+ cwd
155
+ );
156
+ if (statusOk !== null) {
157
+ process.stderr.write(` + Updated ${label} #${issueNumber} project status → "${localStatus}".\n`);
158
+ return true;
159
+ } else {
160
+ process.stderr.write(` x FAILED to update ${label} #${issueNumber} project status.\n`);
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // Sync story issue
166
+ const storyPath = path.isAbsolute(storyFile) ? storyFile : path.join(cwd, storyFile);
167
+ const storyIssue = extractIssueNumberFromFile(cwd, storyFile);
168
+
169
+ if (!storyIssue) {
170
+ result.story.error = 'No GitHub issue linked';
171
+ process.stderr.write(' — Story has no GitHub issue linked. Skipping.\n');
172
+ } else {
173
+ result.story.number = storyIssue;
174
+ const safePath = storyPath.replace(/\\/g, '/');
175
+ try {
176
+ execSync(`gh issue edit ${storyIssue} --repo ${repo} --body-file "${safePath}"`, {
177
+ cwd, shell: 'bash', stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30000,
178
+ });
179
+ result.story.updated = true;
180
+ process.stderr.write(` + Updated GitHub story issue #${storyIssue}.\n`);
181
+ } catch (e) {
182
+ result.story.error = (e.stderr || e.message || 'unknown error').trim();
183
+ process.stderr.write(` x FAILED to update GitHub story issue #${storyIssue}.\n`);
184
+ process.stderr.write(` Error: ${result.story.error}\n`);
185
+ }
186
+
187
+ if (result.story.updated) {
188
+ result.story.status_synced = syncProjectStatus(storyIssue, storyPath, 'Story');
189
+ }
190
+ }
191
+
192
+ // Sync feature issue
193
+ const featureFile = params.feature_file;
194
+ if (featureFile) {
195
+ const featurePath = path.isAbsolute(featureFile) ? featureFile : path.join(cwd, featureFile);
196
+ const featureIssue = extractIssueNumberFromFile(cwd, featureFile);
197
+
198
+ if (!featureIssue) {
199
+ result.feature.error = 'No GitHub issue linked';
200
+ process.stderr.write(' — Feature has no GitHub issue linked. Skipping.\n');
201
+ } else {
202
+ result.feature.number = featureIssue;
203
+ const safePath = featurePath.replace(/\\/g, '/');
204
+ try {
205
+ execSync(`gh issue edit ${featureIssue} --repo ${repo} --body-file "${safePath}"`, {
206
+ cwd, shell: 'bash', stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30000,
207
+ });
208
+ result.feature.updated = true;
209
+ process.stderr.write(` + Updated GitHub feature issue #${featureIssue}.\n`);
210
+ } catch (e) {
211
+ result.feature.error = (e.stderr || e.message || 'unknown error').trim();
212
+ process.stderr.write(` x FAILED to update GitHub feature issue #${featureIssue}.\n`);
213
+ process.stderr.write(` Error: ${result.feature.error}\n`);
214
+ }
215
+
216
+ if (result.feature.updated) {
217
+ result.feature.status_synced = syncProjectStatus(featureIssue, featurePath, 'Feature');
218
+ }
219
+ }
220
+ }
221
+
222
+ output(result, raw);
223
+ }
224
+
225
+ // ─── Resolve Fields ─────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Resolve native issue types and project field definitions.
229
+ * Required args: repo=owner/name owner=org project=number
230
+ * Outputs: { issue_types, project_id, fields }
231
+ */
232
+ function resolveFields(cwd, raw, extraArgs) {
233
+ const params = parseKeyValueArgs(extraArgs);
234
+ const repo = params.repo;
235
+ const owner = params.owner;
236
+ const project = params.project;
237
+
238
+ if (!repo || !owner || !project) {
239
+ error('resolve-fields requires: repo=owner/name owner=org project=number');
240
+ }
241
+
242
+ const repoName = repo.split('/')[1];
243
+
244
+ // Resolve native issue types via GraphQL
245
+ const issueTypes = {};
246
+ const typeQuery = `query { repository(owner: \\"${owner}\\", name: \\"${repoName}\\") { issueTypes(first: 10) { nodes { id name } } } }`;
247
+ const typeResult = execCommand(
248
+ `gh api graphql -f query="${typeQuery}"`,
249
+ cwd
250
+ );
251
+ if (typeResult) {
252
+ try {
253
+ const parsed = JSON.parse(typeResult);
254
+ const nodes = parsed.data?.repository?.issueTypes?.nodes || [];
255
+ for (const node of nodes) {
256
+ issueTypes[node.name] = node.id;
257
+ }
258
+ } catch {}
259
+ }
260
+
261
+ // Resolve project context (project_id + fields)
262
+ const ctx = resolveProjectContext(owner, project, cwd);
263
+
264
+ output({ issue_types: issueTypes, project_id: ctx.project_id, fields: ctx.fields }, raw);
265
+ }
266
+
267
+ // ─── Create Issue ───────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Create a GitHub issue, set native type, add to project, and set project fields.
271
+ *
272
+ * Required args: type, title, repo, owner, project, project_id, type_id
273
+ * Body args (one of): body=... OR body_file=path
274
+ * Optional: status_field_id, status_option_id, priority_field_id, priority_option_id,
275
+ * estimate_field_id, estimate, parent, milestone
276
+ * Outputs: { number, url, item_id, type_set, status_set, priority_set, estimate_set,
277
+ * parent_set, milestone_set }
278
+ */
279
+ function createIssue(cwd, raw, extraArgs) {
280
+ const params = parseKeyValueArgs(extraArgs);
281
+ const type = params.type;
282
+ const title = params.title;
283
+ let body = params.body || '';
284
+ const bodyFile = params.body_file;
285
+ const repo = params.repo;
286
+ const owner = params.owner;
287
+ const project = params.project;
288
+ const projectId = params.project_id;
289
+ const typeId = params.type_id;
290
+
291
+ // Read body from file if provided
292
+ if (bodyFile) {
293
+ const filePath = path.isAbsolute(bodyFile) ? bodyFile : path.join(cwd, bodyFile);
294
+ const content = safeReadFile(filePath);
295
+ if (content !== null) body = content;
296
+ }
297
+
298
+ if (!type || !title || !repo || !owner || !project || !projectId || !typeId) {
299
+ error('create-issue requires: type, title, repo, owner, project, project_id, type_id');
300
+ }
301
+
302
+ const prefixedTitle = `[${type}] ${title}`;
303
+
304
+ // Write body to temp file to avoid shell escaping issues
305
+ const tmpFile = path.join(os.tmpdir(), `ace-issue-${Date.now()}.md`);
306
+ try {
307
+ fs.writeFileSync(tmpFile, body, 'utf-8');
308
+ } catch (e) {
309
+ error(`Failed to write temp body file: ${e.message}`);
310
+ }
311
+
312
+ const safeTmpFile = tmpFile.replace(/\\/g, '/');
313
+
314
+ // Create issue via gh CLI
315
+ let issueUrl = null;
316
+ try {
317
+ issueUrl = execSync(
318
+ `gh issue create --repo ${repo} --title "${prefixedTitle.replace(/"/g, '\\"')}" --body-file "${safeTmpFile}"`,
319
+ { cwd, shell: 'bash', stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30000 }
320
+ ).trim();
321
+ } catch (e) {
322
+ try { fs.unlinkSync(tmpFile); } catch {}
323
+ error(`Failed to create issue: ${(e.stderr || e.message || '').trim()}`);
324
+ }
325
+
326
+ try { fs.unlinkSync(tmpFile); } catch {}
327
+
328
+ // Extract issue number from URL
329
+ const number = issueUrl ? parseInt(issueUrl.split('/').pop(), 10) : null;
330
+
331
+ const result = {
332
+ number,
333
+ url: issueUrl,
334
+ item_id: null,
335
+ type_set: false,
336
+ status_set: false,
337
+ priority_set: false,
338
+ estimate_set: false,
339
+ parent_set: false,
340
+ milestone_set: false,
341
+ };
342
+
343
+ if (!number) {
344
+ output(result, raw);
345
+ return;
346
+ }
347
+
348
+ // Set native issue type via GraphQL mutation
349
+ if (typeId) {
350
+ const repoName = repo.split('/')[1];
351
+ // First get the issue node ID
352
+ const issueQuery = `query { repository(owner: \\"${owner}\\", name: \\"${repoName}\\") { issue(number: ${number}) { id } } }`;
353
+ const issueResult = execCommand(`gh api graphql -f query="${issueQuery}"`, cwd);
354
+ let issueNodeId = null;
355
+ if (issueResult) {
356
+ try {
357
+ issueNodeId = JSON.parse(issueResult).data?.repository?.issue?.id;
358
+ } catch {}
359
+ }
360
+ if (issueNodeId) {
361
+ const typeMutation = `mutation { updateIssue(input: { id: \\"${issueNodeId}\\", issueTypeId: \\"${typeId}\\" }) { issue { id } } }`;
362
+ const typeOk = execCommand(`gh api graphql -f query="${typeMutation}"`, cwd);
363
+ result.type_set = typeOk !== null;
364
+ }
365
+ }
366
+
367
+ // Add to project
368
+ const addResult = execCommand(
369
+ `gh project item-add ${project} --owner ${owner} --url ${issueUrl} --format json`,
370
+ cwd
371
+ );
372
+ if (addResult) {
373
+ try {
374
+ const parsed = JSON.parse(addResult);
375
+ result.item_id = parsed.id || null;
376
+ } catch {}
377
+ }
378
+
379
+ // Set project fields if item was added
380
+ if (result.item_id) {
381
+ // Status (single-select)
382
+ if (params.status_field_id && params.status_option_id) {
383
+ const statusOk = execCommand(
384
+ `gh project item-edit --project-id ${projectId} --id ${result.item_id} --field-id ${params.status_field_id} --single-select-option-id ${params.status_option_id}`,
385
+ cwd
386
+ );
387
+ result.status_set = statusOk !== null;
388
+ }
389
+
390
+ // Priority (single-select)
391
+ if (params.priority_field_id && params.priority_option_id) {
392
+ const priorityOk = execCommand(
393
+ `gh project item-edit --project-id ${projectId} --id ${result.item_id} --field-id ${params.priority_field_id} --single-select-option-id ${params.priority_option_id}`,
394
+ cwd
395
+ );
396
+ result.priority_set = priorityOk !== null;
397
+ }
398
+
399
+ // Estimate (number)
400
+ if (params.estimate_field_id && params.estimate) {
401
+ const estimateOk = execCommand(
402
+ `gh project item-edit --project-id ${projectId} --id ${result.item_id} --field-id ${params.estimate_field_id} --number ${params.estimate}`,
403
+ cwd
404
+ );
405
+ result.estimate_set = estimateOk !== null;
406
+ }
407
+ }
408
+
409
+ // Set parent via GraphQL addSubIssue mutation
410
+ if (params.parent) {
411
+ const repoName = repo.split('/')[1];
412
+ // Get parent issue node ID
413
+ const parentQuery = `query { repository(owner: \\"${owner}\\", name: \\"${repoName}\\") { issue(number: ${params.parent}) { id } } }`;
414
+ const parentResult = execCommand(`gh api graphql -f query="${parentQuery}"`, cwd);
415
+ let parentNodeId = null;
416
+ if (parentResult) {
417
+ try {
418
+ parentNodeId = JSON.parse(parentResult).data?.repository?.issue?.id;
419
+ } catch {}
420
+ }
421
+ // Get child issue node ID
422
+ const childQuery = `query { repository(owner: \\"${owner}\\", name: \\"${repoName}\\") { issue(number: ${number}) { id } } }`;
423
+ const childResult = execCommand(`gh api graphql -f query="${childQuery}"`, cwd);
424
+ let childNodeId = null;
425
+ if (childResult) {
426
+ try {
427
+ childNodeId = JSON.parse(childResult).data?.repository?.issue?.id;
428
+ } catch {}
429
+ }
430
+ if (parentNodeId && childNodeId) {
431
+ const subIssueMutation = `mutation { addSubIssue(input: { issueId: \\"${parentNodeId}\\", subIssueId: \\"${childNodeId}\\" }) { issue { id } } }`;
432
+ const parentOk = execCommand(`gh api graphql -f query="${subIssueMutation}"`, cwd);
433
+ result.parent_set = parentOk !== null;
434
+ }
435
+ }
436
+
437
+ // Set milestone
438
+ if (params.milestone) {
439
+ const milestoneOk = execCommand(
440
+ `gh issue edit ${number} --repo ${repo} --milestone "${params.milestone}"`,
441
+ cwd
442
+ );
443
+ result.milestone_set = milestoneOk !== null;
444
+ }
445
+
446
+ output(result, raw);
447
+ }
448
+
449
+ // ─── Update Issue ───────────────────────────────────────────────────────────
450
+
451
+ /**
452
+ * Update an existing GitHub issue's title, body, and/or project fields.
453
+ *
454
+ * Required args: number, repo
455
+ * Optional: title, body, body_file, owner, project, project_id,
456
+ * status_field_id, status_option_id, priority_field_id, priority_option_id,
457
+ * estimate_field_id, estimate
458
+ * Outputs: { number, updated_title, updated_body, status_set, priority_set, estimate_set }
459
+ */
460
+ function updateIssue(cwd, raw, extraArgs) {
461
+ const params = parseKeyValueArgs(extraArgs);
462
+ const number = params.number;
463
+ const repo = params.repo;
464
+ const title = params.title;
465
+ let body = params.body;
466
+ const bodyFile = params.body_file;
467
+
468
+ if (!number || !repo) {
469
+ error('update-issue requires: number, repo');
470
+ }
471
+
472
+ const result = {
473
+ number: parseInt(number, 10),
474
+ updated_title: false,
475
+ updated_body: false,
476
+ status_set: false,
477
+ priority_set: false,
478
+ estimate_set: false,
479
+ };
480
+
481
+ // Build gh issue edit command parts
482
+ const editParts = [`gh issue edit ${number} --repo ${repo}`];
483
+
484
+ if (title) {
485
+ editParts.push(`--title "${title.replace(/"/g, '\\"')}"`);
486
+ }
487
+
488
+ let tmpFile = null;
489
+ if (bodyFile) {
490
+ const filePath = path.isAbsolute(bodyFile) ? bodyFile : path.join(cwd, bodyFile);
491
+ const safePath = filePath.replace(/\\/g, '/');
492
+ editParts.push(`--body-file "${safePath}"`);
493
+ } else if (body) {
494
+ tmpFile = path.join(os.tmpdir(), `ace-issue-update-${Date.now()}.md`);
495
+ try {
496
+ fs.writeFileSync(tmpFile, body, 'utf-8');
497
+ const safeTmpFile = tmpFile.replace(/\\/g, '/');
498
+ editParts.push(`--body-file "${safeTmpFile}"`);
499
+ } catch (e) {
500
+ error(`Failed to write temp body file: ${e.message}`);
501
+ }
502
+ }
503
+
504
+ // Execute issue edit if we have title or body to update
505
+ if (title || bodyFile || body) {
506
+ try {
507
+ execSync(editParts.join(' '), {
508
+ cwd, shell: 'bash', stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30000,
509
+ });
510
+ if (title) result.updated_title = true;
511
+ if (bodyFile || body) result.updated_body = true;
512
+ } catch (e) {
513
+ process.stderr.write(` x Failed to update issue #${number}: ${(e.stderr || e.message || '').trim()}\n`);
514
+ }
515
+ }
516
+
517
+ if (tmpFile) {
518
+ try { fs.unlinkSync(tmpFile); } catch {}
519
+ }
520
+
521
+ // Update project fields if provided
522
+ const projectId = params.project_id;
523
+
524
+ if (projectId && (params.status_field_id || params.priority_field_id || params.estimate_field_id)) {
525
+ // Find the project item ID for this issue
526
+ const repoParts = repo.split('/');
527
+ const repoOwner = repoParts[0];
528
+ const repoName = repoParts[1] || repoParts[0];
529
+ const itemQuery = `query { repository(owner: \\"${repoOwner}\\", name: \\"${repoName}\\") { issue(number: ${number}) { projectItems(first: 10) { nodes { id project { id } } } } } }`;
530
+ const itemResult = execCommand(`gh api graphql -f query="${itemQuery}"`, cwd);
531
+ let itemId = null;
532
+ if (itemResult) {
533
+ try {
534
+ const parsed = JSON.parse(itemResult);
535
+ const nodes = parsed.data?.repository?.issue?.projectItems?.nodes || [];
536
+ const match = nodes.find(n => n.project?.id === projectId);
537
+ itemId = match?.id || null;
538
+ } catch {}
539
+ }
540
+
541
+ if (itemId) {
542
+ // Status (single-select)
543
+ if (params.status_field_id && params.status_option_id) {
544
+ const statusOk = execCommand(
545
+ `gh project item-edit --project-id ${projectId} --id ${itemId} --field-id ${params.status_field_id} --single-select-option-id ${params.status_option_id}`,
546
+ cwd
547
+ );
548
+ result.status_set = statusOk !== null;
549
+ }
550
+
551
+ // Priority (single-select)
552
+ if (params.priority_field_id && params.priority_option_id) {
553
+ const priorityOk = execCommand(
554
+ `gh project item-edit --project-id ${projectId} --id ${itemId} --field-id ${params.priority_field_id} --single-select-option-id ${params.priority_option_id}`,
555
+ cwd
556
+ );
557
+ result.priority_set = priorityOk !== null;
558
+ }
559
+
560
+ // Estimate (number)
561
+ if (params.estimate_field_id && params.estimate) {
562
+ const estimateOk = execCommand(
563
+ `gh project item-edit --project-id ${projectId} --id ${itemId} --field-id ${params.estimate_field_id} --number ${params.estimate}`,
564
+ cwd
565
+ );
566
+ result.estimate_set = estimateOk !== null;
567
+ }
568
+ } else {
569
+ process.stderr.write(` ! Issue #${number} not found in GitHub Project. Skipping field updates.\n`);
570
+ }
571
+ }
572
+
573
+ output(result, raw);
574
+ }
575
+
576
+ // ─── Fetch Issues ───────────────────────────────────────────────────────────
577
+
578
+ /**
579
+ * Fetch all epics and features from a GitHub Project via paginated GraphQL.
580
+ *
581
+ * Required args: repo=owner/name owner=org project=number
582
+ * Outputs: { epics: [...], features: [...], counts: { total, epics, features, skipped } }
583
+ */
584
+ function fetchIssues(cwd, raw, extraArgs) {
585
+ const params = parseKeyValueArgs(extraArgs);
586
+ const repo = params.repo;
587
+ const owner = params.owner;
588
+ const project = params.project;
589
+
590
+ if (!repo || !owner || !project) {
591
+ error('fetch-issues requires: repo=owner/name owner=org project=number');
592
+ }
593
+
594
+ // Get project ID
595
+ const projectListRaw = execCommand(
596
+ `gh project list --owner ${owner} --format json --limit 20`,
597
+ cwd
598
+ );
599
+ let projectId = null;
600
+ if (projectListRaw) {
601
+ try {
602
+ const parsed = JSON.parse(projectListRaw);
603
+ const projects = parsed.projects || parsed || [];
604
+ const match = projects.find(p => String(p.number) === String(project));
605
+ if (match) projectId = match.id;
606
+ } catch {}
607
+ }
608
+
609
+ if (!projectId) {
610
+ error(`Could not find GitHub Project #${project} for owner "${owner}".`);
611
+ }
612
+
613
+ // Paginated GraphQL query to fetch all project items
614
+ const allItems = [];
615
+ let hasNextPage = true;
616
+ let cursor = null;
617
+
618
+ while (hasNextPage) {
619
+ const afterClause = cursor ? `, after: \\"${cursor}\\"` : '';
620
+ const query = `query {
621
+ node(id: \\"${projectId}\\") {
622
+ ... on ProjectV2 {
623
+ items(first: 100${afterClause}) {
624
+ pageInfo { hasNextPage endCursor }
625
+ nodes {
626
+ id
627
+ fieldValues(first: 20) {
628
+ nodes {
629
+ ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2Field { name } } }
630
+ ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2Field { name } } }
631
+ ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2SingleSelectField { name } } }
632
+ ... on ProjectV2ItemFieldIterationValue { title field { ... on ProjectV2IterationField { name } } }
633
+ }
634
+ }
635
+ content {
636
+ ... on Issue {
637
+ number
638
+ title
639
+ url
640
+ state
641
+ issueType { name }
642
+ parent { number title }
643
+ milestone { title }
644
+ }
645
+ }
646
+ }
647
+ }
648
+ }
649
+ }
650
+ }`.replace(/\n/g, ' ').replace(/\s+/g, ' ');
651
+
652
+ const result = execCommand(`gh api graphql -f query="${query}"`, cwd);
653
+ if (!result) break;
654
+
655
+ try {
656
+ const parsed = JSON.parse(result);
657
+ const items = parsed.data?.node?.items;
658
+ if (!items) break;
659
+
660
+ allItems.push(...(items.nodes || []));
661
+ hasNextPage = items.pageInfo?.hasNextPage || false;
662
+ cursor = items.pageInfo?.endCursor || null;
663
+ } catch {
664
+ break;
665
+ }
666
+ }
667
+
668
+ // Process items into epics and features
669
+ const epics = [];
670
+ const features = [];
671
+ let skipped = 0;
672
+
673
+ for (const item of allItems) {
674
+ const content = item.content;
675
+ if (!content || !content.number) {
676
+ skipped++;
677
+ continue;
678
+ }
679
+
680
+ // Determine type: native issueType first, then title prefix fallback
681
+ let itemType = null;
682
+ if (content.issueType?.name) {
683
+ const nativeType = content.issueType.name;
684
+ if (nativeType === 'Epic' || nativeType === 'Feature') {
685
+ itemType = nativeType;
686
+ }
687
+ }
688
+ if (!itemType) {
689
+ if (content.title.startsWith('[Epic]')) {
690
+ itemType = 'Epic';
691
+ } else if (content.title.startsWith('[Feature]')) {
692
+ itemType = 'Feature';
693
+ }
694
+ }
695
+
696
+ if (!itemType) {
697
+ skipped++;
698
+ continue;
699
+ }
700
+
701
+ // Extract field values
702
+ const fieldValues = {};
703
+ const fvNodes = item.fieldValues?.nodes || [];
704
+ for (const fv of fvNodes) {
705
+ const fieldName = fv.field?.name;
706
+ if (!fieldName) continue;
707
+ if (fv.name !== undefined) fieldValues[fieldName] = fv.name; // single-select
708
+ else if (fv.number !== undefined) fieldValues[fieldName] = fv.number; // number
709
+ else if (fv.text !== undefined) fieldValues[fieldName] = fv.text; // text
710
+ else if (fv.title !== undefined) fieldValues[fieldName] = fv.title; // iteration
711
+ }
712
+
713
+ const entry = {
714
+ number: content.number,
715
+ title: content.title,
716
+ status: fieldValues.Status || null,
717
+ priority: fieldValues.Priority || null,
718
+ estimate: fieldValues.Estimate || null,
719
+ sprint: fieldValues.Sprint || fieldValues.Iteration || null,
720
+ milestone: content.milestone?.title || null,
721
+ url: content.url,
722
+ state: content.state,
723
+ };
724
+
725
+ if (itemType === 'Epic') {
726
+ epics.push(entry);
727
+ } else {
728
+ entry.parent_number = content.parent?.number || null;
729
+ entry.parent_title = content.parent?.title || null;
730
+ features.push(entry);
731
+ }
732
+ }
733
+
734
+ output({
735
+ epics,
736
+ features,
737
+ counts: {
738
+ total: allItems.length,
739
+ epics: epics.length,
740
+ features: features.length,
741
+ skipped,
742
+ },
743
+ }, raw);
744
+ }
745
+
746
+ module.exports = {
747
+ resolveProjectContext,
748
+ syncStory,
749
+ resolveFields,
750
+ createIssue,
751
+ updateIssue,
752
+ fetchIssues,
753
+ };