agile-context-engineering 0.2.2 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/LICENSE +51 -51
  3. package/README.md +324 -323
  4. package/agents/ace-research-synthesizer.md +228 -228
  5. package/agents/ace-technical-application-architect.md +28 -0
  6. package/agents/ace-wiki-mapper.md +445 -334
  7. package/agile-context-engineering/src/ace-tools.test.js +1089 -1089
  8. package/agile-context-engineering/templates/_command.md +53 -53
  9. package/agile-context-engineering/templates/_workflow.xml +16 -16
  10. package/agile-context-engineering/templates/product/product-backlog.xml +231 -231
  11. package/agile-context-engineering/templates/product/story-integration-solution.xml +1 -0
  12. package/agile-context-engineering/templates/product/story-wiki.xml +4 -0
  13. package/agile-context-engineering/templates/wiki/coding-standards.xml +38 -0
  14. package/agile-context-engineering/templates/wiki/decizions.xml +115 -115
  15. package/agile-context-engineering/templates/wiki/guide.xml +137 -137
  16. package/agile-context-engineering/templates/wiki/module-discovery.xml +174 -174
  17. package/agile-context-engineering/templates/wiki/pattern.xml +159 -159
  18. package/agile-context-engineering/templates/wiki/system-architecture.xml +254 -254
  19. package/agile-context-engineering/templates/wiki/system-cross-cutting.xml +197 -197
  20. package/agile-context-engineering/templates/wiki/system.xml +381 -381
  21. package/agile-context-engineering/templates/wiki/walkthrough.xml +255 -0
  22. package/agile-context-engineering/templates/wiki/wiki-readme.xml +297 -276
  23. package/agile-context-engineering/utils/questioning.xml +110 -110
  24. package/agile-context-engineering/workflows/execute-story.xml +1219 -1145
  25. package/agile-context-engineering/workflows/help.xml +540 -540
  26. package/agile-context-engineering/workflows/init-coding-standards.xml +386 -386
  27. package/agile-context-engineering/workflows/map-story.xml +1046 -797
  28. package/agile-context-engineering/workflows/map-subsystem.xml +2 -1
  29. package/agile-context-engineering/workflows/map-walkthrough.xml +457 -0
  30. package/agile-context-engineering/workflows/plan-feature.xml +1495 -1495
  31. package/agile-context-engineering/workflows/plan-story.xml +36 -1
  32. package/agile-context-engineering/workflows/research-integration-solution.xml +1 -0
  33. package/agile-context-engineering/workflows/research-story-wiki.xml +2 -1
  34. package/agile-context-engineering/workflows/research-technical-solution.xml +1 -0
  35. package/agile-context-engineering/workflows/review-story.xml +281 -281
  36. package/agile-context-engineering/workflows/update.xml +238 -207
  37. package/bin/install.js +8 -0
  38. package/commands/ace/execute-story.md +1 -0
  39. package/commands/ace/help.md +93 -93
  40. package/commands/ace/init-coding-standards.md +83 -83
  41. package/commands/ace/map-story.md +165 -156
  42. package/commands/ace/map-subsystem.md +140 -138
  43. package/commands/ace/map-system.md +92 -92
  44. package/commands/ace/map-walkthrough.md +127 -0
  45. package/commands/ace/plan-feature.md +89 -89
  46. package/commands/ace/plan-story.md +15 -1
  47. package/commands/ace/review-story.md +109 -109
  48. package/commands/ace/update.md +56 -54
  49. package/hooks/ace-check-update.js +62 -62
  50. package/hooks/ace-statusline.js +89 -89
  51. package/package.json +4 -3
@@ -1,1089 +1,1089 @@
1
- /**
2
- * ACE Tools Tests
3
- *
4
- * Inspired by GSD's gsd-tools.test.js (https://github.com/gsd-build/get-shit-done).
5
- */
6
-
7
- const { test, describe, beforeEach, afterEach } = require('node:test');
8
- const assert = require('node:assert');
9
- const fs = require('fs');
10
- const path = require('path');
11
- const { execSync } = require('child_process');
12
-
13
- const TOOLS_PATH = path.join(__dirname, 'ace-tools.js');
14
-
15
- // Helper to run ace-tools command
16
- function runAceTools(args, cwd = process.cwd()) {
17
- try {
18
- const result = execSync(`node "${TOOLS_PATH}" ${args}`, {
19
- cwd,
20
- encoding: 'utf-8',
21
- stdio: ['pipe', 'pipe', 'pipe'],
22
- });
23
- return { success: true, output: result.trim() };
24
- } catch (err) {
25
- return {
26
- success: false,
27
- output: err.stdout?.toString().trim() || '',
28
- error: err.stderr?.toString().trim() || err.message,
29
- };
30
- }
31
- }
32
-
33
- // Create temp directory structure
34
- function createTempProject() {
35
- const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ace-test-'));
36
- return tmpDir;
37
- }
38
-
39
- function createTempProjectWithAce() {
40
- const tmpDir = createTempProject();
41
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
42
- return tmpDir;
43
- }
44
-
45
- function cleanup(tmpDir) {
46
- fs.rmSync(tmpDir, { recursive: true, force: true });
47
- }
48
-
49
- // ─── generate-slug ────────────────────────────────────────────────────────────
50
-
51
- describe('generate-slug command', () => {
52
- test('converts text to lowercase slug', () => {
53
- const result = runAceTools('generate-slug "Hello World"');
54
- assert.ok(result.success, `Command failed: ${result.error}`);
55
- const parsed = JSON.parse(result.output);
56
- assert.strictEqual(parsed.slug, 'hello-world');
57
- });
58
-
59
- test('handles special characters', () => {
60
- const result = runAceTools('generate-slug "User Authentication & Login!!!"');
61
- assert.ok(result.success, `Command failed: ${result.error}`);
62
- const parsed = JSON.parse(result.output);
63
- assert.strictEqual(parsed.slug, 'user-authentication-login');
64
- });
65
-
66
- test('trims leading and trailing dashes', () => {
67
- const result = runAceTools('generate-slug "---hello---"');
68
- assert.ok(result.success, `Command failed: ${result.error}`);
69
- const parsed = JSON.parse(result.output);
70
- assert.strictEqual(parsed.slug, 'hello');
71
- });
72
-
73
- test('returns raw slug with --raw flag', () => {
74
- const result = runAceTools('generate-slug "My Epic Name" --raw');
75
- assert.ok(result.success, `Command failed: ${result.error}`);
76
- assert.strictEqual(result.output, 'my-epic-name');
77
- });
78
-
79
- test('errors when no text provided', () => {
80
- const result = runAceTools('generate-slug');
81
- assert.strictEqual(result.success, false);
82
- assert.ok(result.error.includes('text required'), `Expected error about text, got: ${result.error}`);
83
- });
84
-
85
- test('handles multi-word args without quotes', () => {
86
- const result = runAceTools('generate-slug Platform Foundation Setup');
87
- assert.ok(result.success, `Command failed: ${result.error}`);
88
- const parsed = JSON.parse(result.output);
89
- assert.strictEqual(parsed.slug, 'platform-foundation-setup');
90
- });
91
- });
92
-
93
- // ─── current-timestamp ────────────────────────────────────────────────────────
94
-
95
- describe('current-timestamp command', () => {
96
- test('returns full ISO timestamp by default', () => {
97
- const result = runAceTools('current-timestamp');
98
- assert.ok(result.success, `Command failed: ${result.error}`);
99
- const parsed = JSON.parse(result.output);
100
- assert.strictEqual(parsed.format, 'full');
101
- assert.ok(parsed.timestamp.match(/^\d{4}-\d{2}-\d{2}T/), `Expected ISO format, got: ${parsed.timestamp}`);
102
- });
103
-
104
- test('returns date-only with date format', () => {
105
- const result = runAceTools('current-timestamp date');
106
- assert.ok(result.success, `Command failed: ${result.error}`);
107
- const parsed = JSON.parse(result.output);
108
- assert.strictEqual(parsed.format, 'date');
109
- assert.ok(parsed.timestamp.match(/^\d{4}-\d{2}-\d{2}$/), `Expected date format, got: ${parsed.timestamp}`);
110
- });
111
-
112
- test('returns filename-safe with filename format', () => {
113
- const result = runAceTools('current-timestamp filename');
114
- assert.ok(result.success, `Command failed: ${result.error}`);
115
- const parsed = JSON.parse(result.output);
116
- assert.strictEqual(parsed.format, 'filename');
117
- assert.ok(!parsed.timestamp.includes(':'), `Filename format should not contain colons: ${parsed.timestamp}`);
118
- assert.ok(parsed.timestamp.includes('_'), `Filename format should contain underscore separator: ${parsed.timestamp}`);
119
- });
120
-
121
- test('returns raw value with --raw flag', () => {
122
- const result = runAceTools('current-timestamp date --raw');
123
- assert.ok(result.success, `Command failed: ${result.error}`);
124
- assert.ok(result.output.match(/^\d{4}-\d{2}-\d{2}$/), `Expected raw date, got: ${result.output}`);
125
- });
126
- });
127
-
128
- // ─── resolve-model ────────────────────────────────────────────────────────────
129
-
130
- describe('resolve-model command', () => {
131
- let tmpDir;
132
-
133
- beforeEach(() => {
134
- tmpDir = createTempProject();
135
- });
136
-
137
- afterEach(() => {
138
- cleanup(tmpDir);
139
- });
140
-
141
- test('returns quality model for ace-project-researcher', () => {
142
- const result = runAceTools('resolve-model ace-project-researcher', tmpDir);
143
- assert.ok(result.success, `Command failed: ${result.error}`);
144
- const parsed = JSON.parse(result.output);
145
- assert.strictEqual(parsed.model, 'opus');
146
- assert.strictEqual(parsed.agent, 'ace-project-researcher');
147
- assert.strictEqual(parsed.profile, 'quality');
148
- });
149
-
150
- test('returns quality model for ace-research-synthesizer', () => {
151
- const result = runAceTools('resolve-model ace-research-synthesizer', tmpDir);
152
- assert.ok(result.success, `Command failed: ${result.error}`);
153
- const parsed = JSON.parse(result.output);
154
- assert.strictEqual(parsed.model, 'sonnet');
155
- assert.strictEqual(parsed.profile, 'quality');
156
- });
157
-
158
- test('returns quality model for ace-product-owner', () => {
159
- const result = runAceTools('resolve-model ace-product-owner', tmpDir);
160
- assert.ok(result.success, `Command failed: ${result.error}`);
161
- const parsed = JSON.parse(result.output);
162
- assert.strictEqual(parsed.model, 'opus');
163
- assert.strictEqual(parsed.profile, 'quality');
164
- });
165
-
166
- test('respects budget profile from config', () => {
167
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
168
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
169
- model_profile: 'budget',
170
- }));
171
-
172
- const result = runAceTools('resolve-model ace-product-owner', tmpDir);
173
- assert.ok(result.success, `Command failed: ${result.error}`);
174
- const parsed = JSON.parse(result.output);
175
- assert.strictEqual(parsed.model, 'sonnet');
176
- assert.strictEqual(parsed.profile, 'budget');
177
- });
178
-
179
- test('returns sonnet for unknown agent type', () => {
180
- const result = runAceTools('resolve-model unknown-agent', tmpDir);
181
- assert.ok(result.success, `Command failed: ${result.error}`);
182
- const parsed = JSON.parse(result.output);
183
- assert.strictEqual(parsed.model, 'sonnet');
184
- });
185
-
186
- test('returns raw model name with --raw flag', () => {
187
- const result = runAceTools('resolve-model ace-product-owner --raw', tmpDir);
188
- assert.ok(result.success, `Command failed: ${result.error}`);
189
- assert.strictEqual(result.output, 'opus');
190
- });
191
-
192
- test('errors when no agent type provided', () => {
193
- const result = runAceTools('resolve-model');
194
- assert.strictEqual(result.success, false);
195
- assert.ok(result.error.includes('agent-type required'), `Expected error about agent-type, got: ${result.error}`);
196
- });
197
- });
198
-
199
- // ─── verify-path-exists ───────────────────────────────────────────────────────
200
-
201
- describe('verify-path-exists command', () => {
202
- let tmpDir;
203
-
204
- beforeEach(() => {
205
- tmpDir = createTempProject();
206
- });
207
-
208
- afterEach(() => {
209
- cleanup(tmpDir);
210
- });
211
-
212
- test('returns true for existing directory', () => {
213
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
214
- const result = runAceTools('verify-path-exists .ace', tmpDir);
215
- assert.ok(result.success, `Command failed: ${result.error}`);
216
- const parsed = JSON.parse(result.output);
217
- assert.strictEqual(parsed.exists, true);
218
- assert.strictEqual(parsed.path, '.ace');
219
- });
220
-
221
- test('returns false for non-existent path', () => {
222
- const result = runAceTools('verify-path-exists .ace/config.json', tmpDir);
223
- assert.ok(result.success, `Command failed: ${result.error}`);
224
- const parsed = JSON.parse(result.output);
225
- assert.strictEqual(parsed.exists, false);
226
- });
227
-
228
- test('returns true for existing file', () => {
229
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
230
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), '{}');
231
- const result = runAceTools('verify-path-exists .ace/config.json', tmpDir);
232
- assert.ok(result.success, `Command failed: ${result.error}`);
233
- const parsed = JSON.parse(result.output);
234
- assert.strictEqual(parsed.exists, true);
235
- });
236
-
237
- test('returns raw true/false with --raw flag', () => {
238
- const result = runAceTools('verify-path-exists nonexistent --raw', tmpDir);
239
- assert.ok(result.success, `Command failed: ${result.error}`);
240
- assert.strictEqual(result.output, 'false');
241
- });
242
-
243
- test('errors when no path provided', () => {
244
- const result = runAceTools('verify-path-exists', tmpDir);
245
- assert.strictEqual(result.success, false);
246
- assert.ok(result.error.includes('path required'), `Expected error about path, got: ${result.error}`);
247
- });
248
- });
249
-
250
- // ─── load-config ──────────────────────────────────────────────────────────────
251
-
252
- describe('load-config command', () => {
253
- let tmpDir;
254
-
255
- beforeEach(() => {
256
- tmpDir = createTempProject();
257
- });
258
-
259
- afterEach(() => {
260
- cleanup(tmpDir);
261
- });
262
-
263
- test('returns defaults when no config file exists', () => {
264
- const result = runAceTools('load-config', tmpDir);
265
- assert.ok(result.success, `Command failed: ${result.error}`);
266
- const config = JSON.parse(result.output);
267
- assert.strictEqual(config.version, '0.1.0');
268
- assert.strictEqual(config.projectName, '');
269
- assert.strictEqual(config.storage, 'local');
270
- assert.strictEqual(config.commit_docs, true);
271
- assert.strictEqual(config.github.enabled, false);
272
- assert.strictEqual(config.github.repo, null);
273
- assert.strictEqual(config.github.labels.epic, 'ace:epic');
274
- });
275
-
276
- test('reads existing config and merges with defaults', () => {
277
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
278
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
279
- projectName: 'Test Project',
280
- storage: 'github',
281
- github: { enabled: true, repo: 'owner/repo' },
282
- }));
283
-
284
- const result = runAceTools('load-config', tmpDir);
285
- assert.ok(result.success, `Command failed: ${result.error}`);
286
- const config = JSON.parse(result.output);
287
- assert.strictEqual(config.projectName, 'Test Project');
288
- assert.strictEqual(config.storage, 'github');
289
- assert.strictEqual(config.github.enabled, true);
290
- assert.strictEqual(config.github.repo, 'owner/repo');
291
- // Defaults still applied for unset fields
292
- assert.strictEqual(config.version, '0.1.0');
293
- assert.strictEqual(config.github.labels.epic, 'ace:epic');
294
- });
295
-
296
- test('handles malformed JSON gracefully', () => {
297
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
298
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), 'not json');
299
-
300
- const result = runAceTools('load-config', tmpDir);
301
- assert.ok(result.success, `Command failed: ${result.error}`);
302
- const config = JSON.parse(result.output);
303
- // Should return defaults
304
- assert.strictEqual(config.version, '0.1.0');
305
- assert.strictEqual(config.projectName, '');
306
- });
307
- });
308
-
309
- // ─── init new-project ─────────────────────────────────────────────────────────
310
-
311
- describe('init new-project command', () => {
312
- let tmpDir;
313
-
314
- beforeEach(() => {
315
- tmpDir = createTempProject();
316
- });
317
-
318
- afterEach(() => {
319
- cleanup(tmpDir);
320
- });
321
-
322
- test('detects empty project (greenfield)', () => {
323
- const result = runAceTools('init new-project', tmpDir);
324
- assert.ok(result.success, `Command failed: ${result.error}`);
325
- const data = JSON.parse(result.output);
326
-
327
- assert.strictEqual(data.project_exists, false);
328
- assert.strictEqual(data.has_codebase_map, false);
329
- assert.strictEqual(data.planning_exists, false);
330
- assert.strictEqual(data.is_brownfield, false);
331
- assert.strictEqual(data.has_existing_code, false);
332
- assert.strictEqual(data.has_package_file, false);
333
- assert.strictEqual(data.needs_codebase_map, false);
334
- assert.strictEqual(data.has_git, false);
335
- assert.strictEqual(data.commit_docs, true);
336
- });
337
-
338
- test('detects existing code files (brownfield)', () => {
339
- fs.writeFileSync(path.join(tmpDir, 'index.js'), 'console.log("hello");');
340
- fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
341
-
342
- const result = runAceTools('init new-project', tmpDir);
343
- assert.ok(result.success, `Command failed: ${result.error}`);
344
- const data = JSON.parse(result.output);
345
-
346
- assert.strictEqual(data.has_existing_code, true);
347
- assert.strictEqual(data.has_package_file, true);
348
- assert.strictEqual(data.is_brownfield, true);
349
- assert.strictEqual(data.needs_codebase_map, true);
350
- });
351
-
352
- test('detects package file without code files', () => {
353
- fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
354
-
355
- const result = runAceTools('init new-project', tmpDir);
356
- assert.ok(result.success, `Command failed: ${result.error}`);
357
- const data = JSON.parse(result.output);
358
-
359
- assert.strictEqual(data.has_existing_code, false);
360
- assert.strictEqual(data.has_package_file, true);
361
- assert.strictEqual(data.is_brownfield, true);
362
- });
363
-
364
- test('detects nested code files up to depth 3', () => {
365
- const nested = path.join(tmpDir, 'src', 'lib', 'utils');
366
- fs.mkdirSync(nested, { recursive: true });
367
- fs.writeFileSync(path.join(nested, 'helper.ts'), 'export const x = 1;');
368
-
369
- const result = runAceTools('init new-project', tmpDir);
370
- assert.ok(result.success, `Command failed: ${result.error}`);
371
- const data = JSON.parse(result.output);
372
-
373
- assert.strictEqual(data.has_existing_code, true);
374
- assert.strictEqual(data.needs_codebase_map, true);
375
- });
376
-
377
- test('ignores node_modules directory', () => {
378
- fs.mkdirSync(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
379
- fs.writeFileSync(path.join(tmpDir, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
380
-
381
- const result = runAceTools('init new-project', tmpDir);
382
- assert.ok(result.success, `Command failed: ${result.error}`);
383
- const data = JSON.parse(result.output);
384
-
385
- assert.strictEqual(data.has_existing_code, false);
386
- assert.strictEqual(data.needs_codebase_map, false);
387
- });
388
-
389
- test('detects ACE already initialized', () => {
390
- fs.mkdirSync(path.join(tmpDir, '.docs', 'product'), { recursive: true });
391
- fs.writeFileSync(path.join(tmpDir, '.docs', 'product', 'product-vision.md'), '# My Product');
392
-
393
- const result = runAceTools('init new-project', tmpDir);
394
- assert.ok(result.success, `Command failed: ${result.error}`);
395
- const data = JSON.parse(result.output);
396
-
397
- assert.strictEqual(data.project_exists, true);
398
- });
399
-
400
- test('detects git repository', () => {
401
- fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
402
-
403
- const result = runAceTools('init new-project', tmpDir);
404
- assert.ok(result.success, `Command failed: ${result.error}`);
405
- const data = JSON.parse(result.output);
406
-
407
- assert.strictEqual(data.has_git, true);
408
- });
409
-
410
- test('commit_docs defaults to true', () => {
411
- const result = runAceTools('init new-project', tmpDir);
412
- assert.ok(result.success, `Command failed: ${result.error}`);
413
- const data = JSON.parse(result.output);
414
-
415
- assert.strictEqual(data.commit_docs, true);
416
- });
417
-
418
- test('includes pre-resolved models for init agents', () => {
419
- const result = runAceTools('init new-project', tmpDir);
420
- assert.ok(result.success, `Command failed: ${result.error}`);
421
- const data = JSON.parse(result.output);
422
-
423
- assert.strictEqual(data.product_owner_model, 'opus');
424
- assert.strictEqual(data.researcher_model, 'opus');
425
- assert.strictEqual(data.synthesizer_model, 'sonnet');
426
- });
427
-
428
- test('models respect config profile', () => {
429
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
430
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
431
- model_profile: 'budget',
432
- }));
433
-
434
- const result = runAceTools('init new-project', tmpDir);
435
- assert.ok(result.success, `Command failed: ${result.error}`);
436
- const data = JSON.parse(result.output);
437
-
438
- assert.strictEqual(data.product_owner_model, 'sonnet');
439
- assert.strictEqual(data.researcher_model, 'haiku');
440
- assert.strictEqual(data.synthesizer_model, 'haiku');
441
- });
442
-
443
- test('detects Python project files', () => {
444
- fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'flask==2.0');
445
- fs.writeFileSync(path.join(tmpDir, 'app.py'), 'from flask import Flask');
446
-
447
- const result = runAceTools('init new-project', tmpDir);
448
- assert.ok(result.success, `Command failed: ${result.error}`);
449
- const data = JSON.parse(result.output);
450
-
451
- assert.strictEqual(data.has_existing_code, true);
452
- assert.strictEqual(data.has_package_file, true);
453
- assert.strictEqual(data.is_brownfield, true);
454
- });
455
-
456
- test('detects Go project files', () => {
457
- fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example.com/foo');
458
- fs.writeFileSync(path.join(tmpDir, 'main.go'), 'package main');
459
-
460
- const result = runAceTools('init new-project', tmpDir);
461
- assert.ok(result.success, `Command failed: ${result.error}`);
462
- const data = JSON.parse(result.output);
463
-
464
- assert.strictEqual(data.has_existing_code, true);
465
- assert.strictEqual(data.has_package_file, true);
466
- });
467
-
468
- test('detects Rust project files', () => {
469
- fs.writeFileSync(path.join(tmpDir, 'Cargo.toml'), '[package]');
470
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
471
- fs.writeFileSync(path.join(tmpDir, 'src', 'main.rs'), 'fn main() {}');
472
-
473
- const result = runAceTools('init new-project', tmpDir);
474
- assert.ok(result.success, `Command failed: ${result.error}`);
475
- const data = JSON.parse(result.output);
476
-
477
- assert.strictEqual(data.has_existing_code, true);
478
- assert.strictEqual(data.has_package_file, true);
479
- });
480
-
481
- test('needs_codebase_map is false when codebase dir exists', () => {
482
- fs.mkdirSync(path.join(tmpDir, '.ace', 'codebase'), { recursive: true });
483
- fs.writeFileSync(path.join(tmpDir, 'index.js'), 'console.log("hello");');
484
-
485
- const result = runAceTools('init new-project', tmpDir);
486
- assert.ok(result.success, `Command failed: ${result.error}`);
487
- const data = JSON.parse(result.output);
488
-
489
- assert.strictEqual(data.has_existing_code, true);
490
- assert.strictEqual(data.has_codebase_map, true);
491
- assert.strictEqual(data.needs_codebase_map, false);
492
- });
493
-
494
- test('commit_docs respects config override', () => {
495
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
496
- fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
497
- commit_docs: false,
498
- }));
499
-
500
- const result = runAceTools('init new-project', tmpDir);
501
- assert.ok(result.success, `Command failed: ${result.error}`);
502
- const data = JSON.parse(result.output);
503
-
504
- assert.strictEqual(data.commit_docs, false);
505
- });
506
-
507
- test('detects C# project files', () => {
508
- fs.writeFileSync(path.join(tmpDir, 'App.cs'), 'namespace App {}');
509
-
510
- const result = runAceTools('init new-project', tmpDir);
511
- assert.ok(result.success, `Command failed: ${result.error}`);
512
- const data = JSON.parse(result.output);
513
-
514
- assert.strictEqual(data.has_existing_code, true);
515
- assert.strictEqual(data.needs_codebase_map, true);
516
- });
517
-
518
- test('detects .csproj as package file', () => {
519
- fs.writeFileSync(path.join(tmpDir, 'MyApp.csproj'), '<Project Sdk="Microsoft.NET.Sdk" />');
520
-
521
- const result = runAceTools('init new-project', tmpDir);
522
- assert.ok(result.success, `Command failed: ${result.error}`);
523
- const data = JSON.parse(result.output);
524
-
525
- assert.strictEqual(data.has_package_file, true);
526
- assert.strictEqual(data.is_brownfield, true);
527
- });
528
-
529
- test('detects .sln as package file', () => {
530
- fs.writeFileSync(path.join(tmpDir, 'MyApp.sln'), 'Microsoft Visual Studio Solution File');
531
-
532
- const result = runAceTools('init new-project', tmpDir);
533
- assert.ok(result.success, `Command failed: ${result.error}`);
534
- const data = JSON.parse(result.output);
535
-
536
- assert.strictEqual(data.has_package_file, true);
537
- assert.strictEqual(data.is_brownfield, true);
538
- });
539
-
540
- test('detects Java project with build.gradle', () => {
541
- fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'plugins {}');
542
-
543
- const result = runAceTools('init new-project', tmpDir);
544
- assert.ok(result.success, `Command failed: ${result.error}`);
545
- const data = JSON.parse(result.output);
546
-
547
- assert.strictEqual(data.has_package_file, true);
548
- });
549
-
550
- test('has_gh_cli is boolean', () => {
551
- const result = runAceTools('init new-project', tmpDir);
552
- assert.ok(result.success, `Command failed: ${result.error}`);
553
- const data = JSON.parse(result.output);
554
-
555
- assert.strictEqual(typeof data.has_gh_cli, 'boolean');
556
- });
557
- });
558
-
559
- // ─── ensure-settings ──────────────────────────────────────────────────────────
560
-
561
- describe('ensure-settings command', () => {
562
- let tmpDir;
563
-
564
- beforeEach(() => {
565
- tmpDir = createTempProject();
566
- });
567
-
568
- afterEach(() => {
569
- cleanup(tmpDir);
570
- });
571
-
572
- test('creates settings.json with defaults when missing', () => {
573
- const result = runAceTools('ensure-settings', tmpDir);
574
- assert.ok(result.success, `Command failed: ${result.error}`);
575
- const data = JSON.parse(result.output);
576
-
577
- assert.strictEqual(data.created, true);
578
- assert.strictEqual(data.settings.model_profile, 'balanced');
579
- assert.strictEqual(data.settings.commit_docs, true);
580
- assert.strictEqual(data.settings.github_project.enabled, false);
581
- assert.strictEqual(data.settings.github_project.gh_installed, false);
582
- assert.strictEqual(data.settings.github_project.repo, '');
583
- assert.strictEqual(data.settings.github_project.project_number, null);
584
- assert.strictEqual(data.settings.github_project.owner, '');
585
-
586
- // Verify file was actually created
587
- const settingsPath = path.join(tmpDir, '.ace', 'settings.json');
588
- assert.ok(fs.existsSync(settingsPath), 'settings.json should exist on disk');
589
- const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
590
- assert.strictEqual(onDisk.model_profile, 'balanced');
591
- });
592
-
593
- test('creates .ace directory if it does not exist', () => {
594
- const aceDir = path.join(tmpDir, '.ace');
595
- assert.ok(!fs.existsSync(aceDir), '.ace dir should not exist yet');
596
-
597
- const result = runAceTools('ensure-settings', tmpDir);
598
- assert.ok(result.success, `Command failed: ${result.error}`);
599
- const data = JSON.parse(result.output);
600
-
601
- assert.strictEqual(data.created, true);
602
- assert.ok(fs.existsSync(aceDir), '.ace dir should be created');
603
- });
604
-
605
- test('does not overwrite existing settings.json', () => {
606
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
607
- const customSettings = {
608
- model_profile: 'quality',
609
- commit_docs: false,
610
- github_project: {
611
- enabled: true,
612
- gh_installed: true,
613
- repo: 'owner/repo',
614
- project_number: 5,
615
- owner: 'owner',
616
- },
617
- };
618
- fs.writeFileSync(path.join(tmpDir, '.ace', 'settings.json'), JSON.stringify(customSettings, null, 2));
619
-
620
- const result = runAceTools('ensure-settings', tmpDir);
621
- assert.ok(result.success, `Command failed: ${result.error}`);
622
- const data = JSON.parse(result.output);
623
-
624
- assert.strictEqual(data.created, false);
625
- assert.strictEqual(data.settings.model_profile, 'quality');
626
- assert.strictEqual(data.settings.commit_docs, false);
627
- assert.strictEqual(data.settings.github_project.enabled, true);
628
- assert.strictEqual(data.settings.github_project.project_number, 5);
629
- });
630
- });
631
-
632
- // ─── init setup-github ────────────────────────────────────────────────────────
633
-
634
- describe('init setup-github command', () => {
635
- let tmpDir;
636
-
637
- beforeEach(() => {
638
- tmpDir = createTempProject();
639
- });
640
-
641
- afterEach(() => {
642
- cleanup(tmpDir);
643
- });
644
-
645
- test('returns gh_installed as boolean', () => {
646
- const result = runAceTools('init setup-github', tmpDir);
647
- assert.ok(result.success, `Command failed: ${result.error}`);
648
- const data = JSON.parse(result.output);
649
-
650
- assert.strictEqual(typeof data.gh_installed, 'boolean');
651
- });
652
-
653
- test('returns projects as array', () => {
654
- const result = runAceTools('init setup-github', tmpDir);
655
- assert.ok(result.success, `Command failed: ${result.error}`);
656
- const data = JSON.parse(result.output);
657
-
658
- assert.ok(Array.isArray(data.projects), 'projects should be an array');
659
- });
660
-
661
- test('returns current_settings object', () => {
662
- const result = runAceTools('init setup-github', tmpDir);
663
- assert.ok(result.success, `Command failed: ${result.error}`);
664
- const data = JSON.parse(result.output);
665
-
666
- assert.ok(data.current_settings !== undefined, 'current_settings should be present');
667
- assert.strictEqual(typeof data.current_settings.enabled, 'boolean');
668
- });
669
-
670
- test('returns repo and owner as strings', () => {
671
- const result = runAceTools('init setup-github', tmpDir);
672
- assert.ok(result.success, `Command failed: ${result.error}`);
673
- const data = JSON.parse(result.output);
674
-
675
- assert.strictEqual(typeof data.repo, 'string');
676
- assert.strictEqual(typeof data.owner, 'string');
677
- });
678
- });
679
-
680
- // ─── write-github-settings ────────────────────────────────────────────────────
681
-
682
- describe('write-github-settings command', () => {
683
- let tmpDir;
684
-
685
- beforeEach(() => {
686
- tmpDir = createTempProjectWithAce();
687
- // Seed a settings.json with defaults
688
- const defaults = {
689
- model_profile: 'balanced',
690
- commit_docs: true,
691
- github_project: {
692
- enabled: false,
693
- gh_installed: false,
694
- repo: '',
695
- project_number: null,
696
- owner: '',
697
- },
698
- };
699
- fs.writeFileSync(path.join(tmpDir, '.ace', 'settings.json'), JSON.stringify(defaults, null, 2));
700
- });
701
-
702
- afterEach(() => {
703
- cleanup(tmpDir);
704
- });
705
-
706
- test('writes key=value pairs to settings.json', () => {
707
- const result = runAceTools('write-github-settings enabled=true repo=owner/repo project_number=3 owner=owner gh_installed=true', tmpDir);
708
- assert.ok(result.success, `Command failed: ${result.error}`);
709
- const data = JSON.parse(result.output);
710
-
711
- assert.strictEqual(data.written, true);
712
- assert.strictEqual(data.settings.github_project.enabled, true);
713
- assert.strictEqual(data.settings.github_project.repo, 'owner/repo');
714
- assert.strictEqual(data.settings.github_project.project_number, 3);
715
- assert.strictEqual(data.settings.github_project.owner, 'owner');
716
- assert.strictEqual(data.settings.github_project.gh_installed, true);
717
-
718
- // Verify persisted to disk
719
- const onDisk = JSON.parse(fs.readFileSync(path.join(tmpDir, '.ace', 'settings.json'), 'utf-8'));
720
- assert.strictEqual(onDisk.github_project.enabled, true);
721
- assert.strictEqual(onDisk.github_project.project_number, 3);
722
- });
723
-
724
- test('preserves non-github settings when writing', () => {
725
- const result = runAceTools('write-github-settings enabled=true', tmpDir);
726
- assert.ok(result.success, `Command failed: ${result.error}`);
727
- const data = JSON.parse(result.output);
728
-
729
- assert.strictEqual(data.settings.model_profile, 'balanced');
730
- assert.strictEqual(data.settings.commit_docs, true);
731
- });
732
-
733
- test('handles project_number=null', () => {
734
- // First set a number
735
- runAceTools('write-github-settings project_number=5', tmpDir);
736
- // Then reset to null
737
- const result = runAceTools('write-github-settings project_number=null', tmpDir);
738
- assert.ok(result.success, `Command failed: ${result.error}`);
739
- const data = JSON.parse(result.output);
740
-
741
- assert.strictEqual(data.settings.github_project.project_number, null);
742
- });
743
- });
744
-
745
- // ─── init research-story ─────────────────────────────────────────────────────
746
-
747
- const SAMPLE_STORY = `# S3: Display OAuth Provider Buttons
748
-
749
- **Feature**: F3 OAuth2 Login Flow | **Epic**: #45 User Authentication
750
- **Status**: Refined | **Size**: 3 | **Sprint**: Sprint 2 | **Link**: [#95](https://github.com/owner/repo/issues/95)
751
-
752
- ## User Story
753
-
754
- > As a returning customer,
755
- > I want to click a Google or GitHub login button,
756
- > so that I can authenticate without remembering a site-specific password.
757
-
758
- ## Description
759
-
760
- This story adds OAuth provider buttons to the login page. It builds on the
761
- auth service foundation (S1) and enables the token exchange flow (S4).
762
-
763
- ## Acceptance Criteria
764
-
765
- ### Scenario: Successful Google login
766
-
767
- **Given** the user is on the login page and has a valid Google account
768
- **When** they click the "Sign in with Google" button and complete Google's OAuth flow
769
- **Then** they are redirected to the dashboard and see their Google profile name
770
-
771
- ### Scenario: Provider unavailable
772
-
773
- **Given** the user is on the login page and the Google OAuth service is unreachable
774
- **When** they click the "Sign in with Google" button
775
- **Then** they see an error message "Login service temporarily unavailable. Please try again."
776
-
777
- ### Scenario: GitHub login button displayed
778
-
779
- **Given** the user navigates to the login page
780
- **When** the page loads
781
- **Then** they see a "Sign in with GitHub" button alongside the Google button
782
-
783
- ## Out of Scope
784
-
785
- - Token refresh logic (handled by S4)
786
- - Account linking (future feature)
787
-
788
- ## Dependencies
789
-
790
- ### Blocked By
791
- - S1 Auth service foundation
792
-
793
- ### Blocks
794
- - S4 Token exchange flow
795
-
796
- ### External
797
- - Google OAuth API — available
798
-
799
- ## Definition of Done
800
-
801
- - [ ] All acceptance criteria scenarios pass
802
- - [ ] Code reviewed and approved
803
- - [ ] Tests written and passing
804
- - [ ] CI pipeline green
805
- - [ ] Documentation updated (if applicable)
806
- - [ ] Product Owner verified
807
-
808
- ## Relevant Wiki
809
-
810
- ### System-Wide
811
-
812
- - \`.docs/wiki/system-wide/system-structure.md\` — Mandatory system-wide context
813
- - \`.docs/wiki/system-wide/system-architecture.md\` — Mandatory system-wide context
814
- - \`.docs/wiki/system-wide/coding-standards.md\` — Mandatory system-wide context
815
- - \`.docs/wiki/system-wide/testing-framework.md\` — Mandatory system-wide context
816
-
817
- ### Systems
818
- - \`.docs/wiki/subsystems/auth/systems/oauth-provider.md\` — Implements the provider abstraction this story extends
819
-
820
- ### Patterns
821
- - \`.docs/wiki/subsystems/auth/patterns/strategy-pattern.md\` — Each OAuth provider is a strategy; new provider must follow this
822
-
823
- ### Decisions
824
- - \`.docs/wiki/subsystems/auth/decisions/adr-003-jwt-over-sessions.md\` — Constrains token format to JWT
825
- `;
826
-
827
- describe('init research-story command', () => {
828
- let tmpDir;
829
-
830
- beforeEach(() => {
831
- tmpDir = createTempProject();
832
- fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
833
- });
834
-
835
- afterEach(() => {
836
- cleanup(tmpDir);
837
- });
838
-
839
- test('parses story from file and extracts metadata', () => {
840
- // Create story directory structure
841
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', '45-user-authentication', 'f3-oauth2-login-flow', 's3-display-oauth-provider-buttons');
842
- fs.mkdirSync(storyDir, { recursive: true });
843
- const storyFile = path.join(storyDir, 's3-display-oauth-provider-buttons.md');
844
- fs.writeFileSync(storyFile, SAMPLE_STORY);
845
-
846
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
847
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
848
- assert.ok(result.success, `Command failed: ${result.error}`);
849
- const data = JSON.parse(result.output);
850
-
851
- assert.strictEqual(data.story_valid, true);
852
- assert.strictEqual(data.story_error, null);
853
- assert.strictEqual(data.story_source, 'file');
854
- assert.strictEqual(data.story.id, 'S3');
855
- assert.strictEqual(data.story.title, 'Display OAuth Provider Buttons');
856
- assert.strictEqual(data.story.status, 'Refined');
857
- assert.strictEqual(data.story.size, '3');
858
- assert.strictEqual(data.feature.id, 'F3');
859
- assert.strictEqual(data.feature.title, 'OAuth2 Login Flow');
860
- assert.strictEqual(data.epic.id, '#45');
861
- assert.strictEqual(data.epic.title, 'User Authentication');
862
- });
863
-
864
- test('extracts user story and description', () => {
865
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
866
- fs.mkdirSync(storyDir, { recursive: true });
867
- const storyFile = path.join(storyDir, 'story.md');
868
- fs.writeFileSync(storyFile, SAMPLE_STORY);
869
-
870
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
871
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
872
- assert.ok(result.success, `Command failed: ${result.error}`);
873
- const data = JSON.parse(result.output);
874
-
875
- assert.ok(data.user_story.includes('As a returning customer'), `user_story should contain persona: ${data.user_story}`);
876
- assert.ok(data.user_story.includes('authenticate without remembering'), `user_story should contain benefit`);
877
- assert.ok(data.description.includes('OAuth provider buttons'), `description should contain story details: ${data.description}`);
878
- assert.strictEqual(data.acceptance_criteria_count, 3);
879
- });
880
-
881
- test('extracts wiki references with categories', () => {
882
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
883
- fs.mkdirSync(storyDir, { recursive: true });
884
- const storyFile = path.join(storyDir, 'story.md');
885
- fs.writeFileSync(storyFile, SAMPLE_STORY);
886
-
887
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
888
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
889
- assert.ok(result.success, `Command failed: ${result.error}`);
890
- const data = JSON.parse(result.output);
891
-
892
- assert.strictEqual(data.wiki_references.system_wide.length, 4);
893
- assert.ok(data.wiki_references.system_wide.includes('.docs/wiki/system-wide/system-structure.md'));
894
- assert.ok(data.wiki_references.system_wide.includes('.docs/wiki/system-wide/coding-standards.md'));
895
-
896
- assert.strictEqual(data.wiki_references.subsystem_docs.length, 3);
897
- const oauthDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('oauth-provider'));
898
- assert.ok(oauthDoc, 'Should find oauth-provider doc');
899
- assert.strictEqual(oauthDoc.category, 'systems');
900
-
901
- const strategyDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('strategy-pattern'));
902
- assert.ok(strategyDoc, 'Should find strategy-pattern doc');
903
- assert.strictEqual(strategyDoc.category, 'patterns');
904
-
905
- const adrDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('adr-003'));
906
- assert.ok(adrDoc, 'Should find ADR doc');
907
- assert.strictEqual(adrDoc.category, 'decisions');
908
-
909
- assert.strictEqual(data.wiki_references.total_count, 7);
910
- });
911
-
912
- test('computes paths from file location', () => {
913
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'e1-auth', 'f3-oauth', 's3-buttons');
914
- fs.mkdirSync(storyDir, { recursive: true });
915
- const storyFile = path.join(storyDir, 's3-buttons.md');
916
- fs.writeFileSync(storyFile, SAMPLE_STORY);
917
-
918
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
919
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
920
- assert.ok(result.success, `Command failed: ${result.error}`);
921
- const data = JSON.parse(result.output);
922
-
923
- assert.ok(data.paths, 'paths should be present');
924
- assert.ok(data.paths.story_dir.includes('s3-buttons'), `story_dir should contain story slug: ${data.paths.story_dir}`);
925
- assert.ok(data.paths.external_analysis_file.endsWith('external-analysis.md'), `external_analysis_file: ${data.paths.external_analysis_file}`);
926
- assert.ok(data.paths.integration_analysis_file.endsWith('integration-analysis.md'));
927
- assert.ok(data.paths.feature_file.endsWith('f3-oauth.md'), `feature_file: ${data.paths.feature_file}`);
928
- });
929
-
930
- test('detects existing artifacts', () => {
931
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
932
- fs.mkdirSync(storyDir, { recursive: true });
933
- const storyFile = path.join(storyDir, 'story.md');
934
- fs.writeFileSync(storyFile, SAMPLE_STORY);
935
- // Create external analysis
936
- fs.writeFileSync(path.join(storyDir, 'external-analysis.md'), '# External Analysis');
937
-
938
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
939
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
940
- assert.ok(result.success, `Command failed: ${result.error}`);
941
- const data = JSON.parse(result.output);
942
-
943
- assert.strictEqual(data.has_external_analysis, true);
944
- assert.strictEqual(data.has_integration_analysis, false);
945
- });
946
-
947
- test('verifies wiki doc existence', () => {
948
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
949
- fs.mkdirSync(storyDir, { recursive: true });
950
- const storyFile = path.join(storyDir, 'story.md');
951
- fs.writeFileSync(storyFile, SAMPLE_STORY);
952
-
953
- // Create some wiki docs but not all
954
- fs.mkdirSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide'), { recursive: true });
955
- fs.writeFileSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide', 'system-structure.md'), '# Structure');
956
- fs.writeFileSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide', 'coding-standards.md'), '# Standards');
957
-
958
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
959
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
960
- assert.ok(result.success, `Command failed: ${result.error}`);
961
- const data = JSON.parse(result.output);
962
-
963
- assert.ok(data.wiki_docs_exist.existing.length >= 2, `Should find at least 2 existing: ${JSON.stringify(data.wiki_docs_exist.existing)}`);
964
- assert.ok(data.wiki_docs_exist.missing.length >= 2, `Should find at least 2 missing: ${JSON.stringify(data.wiki_docs_exist.missing)}`);
965
- assert.ok(data.wiki_docs_exist.existing.includes('.docs/wiki/system-wide/system-structure.md'));
966
- });
967
-
968
- test('returns error for non-existent file', () => {
969
- const result = runAceTools('init research-story nonexistent.md', tmpDir);
970
- assert.ok(result.success, `Command should still succeed with error in JSON: ${result.error}`);
971
- const data = JSON.parse(result.output);
972
-
973
- assert.strictEqual(data.story_valid, false);
974
- assert.ok(data.story_error.includes('not found'), `story_error: ${data.story_error}`);
975
- });
976
-
977
- test('returns error for no parameter', () => {
978
- const result = runAceTools('init research-story', tmpDir);
979
- assert.ok(result.success, `Command should still succeed with error in JSON: ${result.error}`);
980
- const data = JSON.parse(result.output);
981
-
982
- assert.strictEqual(data.story_valid, false);
983
- assert.ok(data.story_error !== null, 'Should have a story_error');
984
- });
985
-
986
- test('includes model and environment fields', () => {
987
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
988
- fs.mkdirSync(storyDir, { recursive: true });
989
- const storyFile = path.join(storyDir, 'story.md');
990
- fs.writeFileSync(storyFile, SAMPLE_STORY);
991
-
992
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
993
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
994
- assert.ok(result.success, `Command failed: ${result.error}`);
995
- const data = JSON.parse(result.output);
996
-
997
- assert.ok(typeof data.analyst_model === 'string', 'analyst_model should be a string');
998
- assert.ok(typeof data.mapper_model === 'string', 'mapper_model should be a string');
999
- assert.ok(typeof data.has_git === 'boolean', 'has_git should be boolean');
1000
- assert.ok(typeof data.has_gh_cli === 'boolean', 'has_gh_cli should be boolean');
1001
- assert.ok(typeof data.commit_docs === 'boolean', 'commit_docs should be boolean');
1002
- assert.ok(data.github_project !== undefined, 'github_project should be present');
1003
- });
1004
-
1005
- test('handles story without Relevant Wiki section', () => {
1006
- const minimalStory = `# S1: Basic Story
1007
-
1008
- **Feature**: F1 Auth | **Epic**: E1 Security
1009
- **Status**: Todo | **Size**: 2
1010
-
1011
- ## User Story
1012
-
1013
- > As a user,
1014
- > I want to log in,
1015
- > so that I can access my account.
1016
-
1017
- ## Description
1018
-
1019
- Basic login functionality.
1020
-
1021
- ## Acceptance Criteria
1022
-
1023
- ### Scenario: Successful login
1024
-
1025
- **Given** valid credentials
1026
- **When** user submits login form
1027
- **Then** user is redirected to dashboard
1028
-
1029
- ## Definition of Done
1030
-
1031
- - [ ] All AC pass
1032
- `;
1033
- const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
1034
- fs.mkdirSync(storyDir, { recursive: true });
1035
- const storyFile = path.join(storyDir, 'story.md');
1036
- fs.writeFileSync(storyFile, minimalStory);
1037
-
1038
- const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
1039
- const result = runAceTools(`init research-story ${relPath}`, tmpDir);
1040
- assert.ok(result.success, `Command failed: ${result.error}`);
1041
- const data = JSON.parse(result.output);
1042
-
1043
- assert.strictEqual(data.story_valid, true);
1044
- assert.strictEqual(data.wiki_references.total_count, 0);
1045
- assert.strictEqual(data.acceptance_criteria_count, 1);
1046
- assert.strictEqual(data.story.id, 'S1');
1047
- });
1048
-
1049
- test('classifies GitHub URL correctly', () => {
1050
- // We can't actually fetch from GitHub in tests, but we can verify it tries
1051
- const result = runAceTools('init research-story https://github.com/owner/repo/issues/123', tmpDir);
1052
- assert.ok(result.success, `Command should succeed: ${result.error}`);
1053
- const data = JSON.parse(result.output);
1054
-
1055
- assert.strictEqual(data.story_source, 'github');
1056
- // It will either fail due to no gh cli or fail to fetch — both are valid
1057
- // The point is it classified correctly
1058
- });
1059
-
1060
- test('classifies issue number correctly', () => {
1061
- const result = runAceTools('init research-story 42', tmpDir);
1062
- assert.ok(result.success, `Command should succeed: ${result.error}`);
1063
- const data = JSON.parse(result.output);
1064
-
1065
- assert.strictEqual(data.story_source, 'github');
1066
- });
1067
- });
1068
-
1069
- // ─── CLI error handling ───────────────────────────────────────────────────────
1070
-
1071
- describe('CLI error handling', () => {
1072
- test('errors on unknown command', () => {
1073
- const result = runAceTools('nonexistent');
1074
- assert.strictEqual(result.success, false);
1075
- assert.ok(result.error.includes('Unknown command'), `Expected unknown command error, got: ${result.error}`);
1076
- });
1077
-
1078
- test('errors when no command provided', () => {
1079
- const result = runAceTools('');
1080
- assert.strictEqual(result.success, false);
1081
- assert.ok(result.error.includes('Usage'), `Expected usage message, got: ${result.error}`);
1082
- });
1083
-
1084
- test('errors on unknown init subcommand', () => {
1085
- const result = runAceTools('init nonexistent');
1086
- assert.strictEqual(result.success, false);
1087
- assert.ok(result.error.includes('Unknown init subcommand'), `Expected subcommand error, got: ${result.error}`);
1088
- });
1089
- });
1
+ /**
2
+ * ACE Tools Tests
3
+ *
4
+ * Inspired by GSD's gsd-tools.test.js (https://github.com/gsd-build/get-shit-done).
5
+ */
6
+
7
+ const { test, describe, beforeEach, afterEach } = require('node:test');
8
+ const assert = require('node:assert');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execSync } = require('child_process');
12
+
13
+ const TOOLS_PATH = path.join(__dirname, 'ace-tools.js');
14
+
15
+ // Helper to run ace-tools command
16
+ function runAceTools(args, cwd = process.cwd()) {
17
+ try {
18
+ const result = execSync(`node "${TOOLS_PATH}" ${args}`, {
19
+ cwd,
20
+ encoding: 'utf-8',
21
+ stdio: ['pipe', 'pipe', 'pipe'],
22
+ });
23
+ return { success: true, output: result.trim() };
24
+ } catch (err) {
25
+ return {
26
+ success: false,
27
+ output: err.stdout?.toString().trim() || '',
28
+ error: err.stderr?.toString().trim() || err.message,
29
+ };
30
+ }
31
+ }
32
+
33
+ // Create temp directory structure
34
+ function createTempProject() {
35
+ const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ace-test-'));
36
+ return tmpDir;
37
+ }
38
+
39
+ function createTempProjectWithAce() {
40
+ const tmpDir = createTempProject();
41
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
42
+ return tmpDir;
43
+ }
44
+
45
+ function cleanup(tmpDir) {
46
+ fs.rmSync(tmpDir, { recursive: true, force: true });
47
+ }
48
+
49
+ // ─── generate-slug ────────────────────────────────────────────────────────────
50
+
51
+ describe('generate-slug command', () => {
52
+ test('converts text to lowercase slug', () => {
53
+ const result = runAceTools('generate-slug "Hello World"');
54
+ assert.ok(result.success, `Command failed: ${result.error}`);
55
+ const parsed = JSON.parse(result.output);
56
+ assert.strictEqual(parsed.slug, 'hello-world');
57
+ });
58
+
59
+ test('handles special characters', () => {
60
+ const result = runAceTools('generate-slug "User Authentication & Login!!!"');
61
+ assert.ok(result.success, `Command failed: ${result.error}`);
62
+ const parsed = JSON.parse(result.output);
63
+ assert.strictEqual(parsed.slug, 'user-authentication-login');
64
+ });
65
+
66
+ test('trims leading and trailing dashes', () => {
67
+ const result = runAceTools('generate-slug "---hello---"');
68
+ assert.ok(result.success, `Command failed: ${result.error}`);
69
+ const parsed = JSON.parse(result.output);
70
+ assert.strictEqual(parsed.slug, 'hello');
71
+ });
72
+
73
+ test('returns raw slug with --raw flag', () => {
74
+ const result = runAceTools('generate-slug "My Epic Name" --raw');
75
+ assert.ok(result.success, `Command failed: ${result.error}`);
76
+ assert.strictEqual(result.output, 'my-epic-name');
77
+ });
78
+
79
+ test('errors when no text provided', () => {
80
+ const result = runAceTools('generate-slug');
81
+ assert.strictEqual(result.success, false);
82
+ assert.ok(result.error.includes('text required'), `Expected error about text, got: ${result.error}`);
83
+ });
84
+
85
+ test('handles multi-word args without quotes', () => {
86
+ const result = runAceTools('generate-slug Platform Foundation Setup');
87
+ assert.ok(result.success, `Command failed: ${result.error}`);
88
+ const parsed = JSON.parse(result.output);
89
+ assert.strictEqual(parsed.slug, 'platform-foundation-setup');
90
+ });
91
+ });
92
+
93
+ // ─── current-timestamp ────────────────────────────────────────────────────────
94
+
95
+ describe('current-timestamp command', () => {
96
+ test('returns full ISO timestamp by default', () => {
97
+ const result = runAceTools('current-timestamp');
98
+ assert.ok(result.success, `Command failed: ${result.error}`);
99
+ const parsed = JSON.parse(result.output);
100
+ assert.strictEqual(parsed.format, 'full');
101
+ assert.ok(parsed.timestamp.match(/^\d{4}-\d{2}-\d{2}T/), `Expected ISO format, got: ${parsed.timestamp}`);
102
+ });
103
+
104
+ test('returns date-only with date format', () => {
105
+ const result = runAceTools('current-timestamp date');
106
+ assert.ok(result.success, `Command failed: ${result.error}`);
107
+ const parsed = JSON.parse(result.output);
108
+ assert.strictEqual(parsed.format, 'date');
109
+ assert.ok(parsed.timestamp.match(/^\d{4}-\d{2}-\d{2}$/), `Expected date format, got: ${parsed.timestamp}`);
110
+ });
111
+
112
+ test('returns filename-safe with filename format', () => {
113
+ const result = runAceTools('current-timestamp filename');
114
+ assert.ok(result.success, `Command failed: ${result.error}`);
115
+ const parsed = JSON.parse(result.output);
116
+ assert.strictEqual(parsed.format, 'filename');
117
+ assert.ok(!parsed.timestamp.includes(':'), `Filename format should not contain colons: ${parsed.timestamp}`);
118
+ assert.ok(parsed.timestamp.includes('_'), `Filename format should contain underscore separator: ${parsed.timestamp}`);
119
+ });
120
+
121
+ test('returns raw value with --raw flag', () => {
122
+ const result = runAceTools('current-timestamp date --raw');
123
+ assert.ok(result.success, `Command failed: ${result.error}`);
124
+ assert.ok(result.output.match(/^\d{4}-\d{2}-\d{2}$/), `Expected raw date, got: ${result.output}`);
125
+ });
126
+ });
127
+
128
+ // ─── resolve-model ────────────────────────────────────────────────────────────
129
+
130
+ describe('resolve-model command', () => {
131
+ let tmpDir;
132
+
133
+ beforeEach(() => {
134
+ tmpDir = createTempProject();
135
+ });
136
+
137
+ afterEach(() => {
138
+ cleanup(tmpDir);
139
+ });
140
+
141
+ test('returns quality model for ace-project-researcher', () => {
142
+ const result = runAceTools('resolve-model ace-project-researcher', tmpDir);
143
+ assert.ok(result.success, `Command failed: ${result.error}`);
144
+ const parsed = JSON.parse(result.output);
145
+ assert.strictEqual(parsed.model, 'opus');
146
+ assert.strictEqual(parsed.agent, 'ace-project-researcher');
147
+ assert.strictEqual(parsed.profile, 'quality');
148
+ });
149
+
150
+ test('returns quality model for ace-research-synthesizer', () => {
151
+ const result = runAceTools('resolve-model ace-research-synthesizer', tmpDir);
152
+ assert.ok(result.success, `Command failed: ${result.error}`);
153
+ const parsed = JSON.parse(result.output);
154
+ assert.strictEqual(parsed.model, 'sonnet');
155
+ assert.strictEqual(parsed.profile, 'quality');
156
+ });
157
+
158
+ test('returns quality model for ace-product-owner', () => {
159
+ const result = runAceTools('resolve-model ace-product-owner', tmpDir);
160
+ assert.ok(result.success, `Command failed: ${result.error}`);
161
+ const parsed = JSON.parse(result.output);
162
+ assert.strictEqual(parsed.model, 'opus');
163
+ assert.strictEqual(parsed.profile, 'quality');
164
+ });
165
+
166
+ test('respects budget profile from config', () => {
167
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
168
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
169
+ model_profile: 'budget',
170
+ }));
171
+
172
+ const result = runAceTools('resolve-model ace-product-owner', tmpDir);
173
+ assert.ok(result.success, `Command failed: ${result.error}`);
174
+ const parsed = JSON.parse(result.output);
175
+ assert.strictEqual(parsed.model, 'sonnet');
176
+ assert.strictEqual(parsed.profile, 'budget');
177
+ });
178
+
179
+ test('returns sonnet for unknown agent type', () => {
180
+ const result = runAceTools('resolve-model unknown-agent', tmpDir);
181
+ assert.ok(result.success, `Command failed: ${result.error}`);
182
+ const parsed = JSON.parse(result.output);
183
+ assert.strictEqual(parsed.model, 'sonnet');
184
+ });
185
+
186
+ test('returns raw model name with --raw flag', () => {
187
+ const result = runAceTools('resolve-model ace-product-owner --raw', tmpDir);
188
+ assert.ok(result.success, `Command failed: ${result.error}`);
189
+ assert.strictEqual(result.output, 'opus');
190
+ });
191
+
192
+ test('errors when no agent type provided', () => {
193
+ const result = runAceTools('resolve-model');
194
+ assert.strictEqual(result.success, false);
195
+ assert.ok(result.error.includes('agent-type required'), `Expected error about agent-type, got: ${result.error}`);
196
+ });
197
+ });
198
+
199
+ // ─── verify-path-exists ───────────────────────────────────────────────────────
200
+
201
+ describe('verify-path-exists command', () => {
202
+ let tmpDir;
203
+
204
+ beforeEach(() => {
205
+ tmpDir = createTempProject();
206
+ });
207
+
208
+ afterEach(() => {
209
+ cleanup(tmpDir);
210
+ });
211
+
212
+ test('returns true for existing directory', () => {
213
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
214
+ const result = runAceTools('verify-path-exists .ace', tmpDir);
215
+ assert.ok(result.success, `Command failed: ${result.error}`);
216
+ const parsed = JSON.parse(result.output);
217
+ assert.strictEqual(parsed.exists, true);
218
+ assert.strictEqual(parsed.path, '.ace');
219
+ });
220
+
221
+ test('returns false for non-existent path', () => {
222
+ const result = runAceTools('verify-path-exists .ace/config.json', tmpDir);
223
+ assert.ok(result.success, `Command failed: ${result.error}`);
224
+ const parsed = JSON.parse(result.output);
225
+ assert.strictEqual(parsed.exists, false);
226
+ });
227
+
228
+ test('returns true for existing file', () => {
229
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
230
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), '{}');
231
+ const result = runAceTools('verify-path-exists .ace/config.json', tmpDir);
232
+ assert.ok(result.success, `Command failed: ${result.error}`);
233
+ const parsed = JSON.parse(result.output);
234
+ assert.strictEqual(parsed.exists, true);
235
+ });
236
+
237
+ test('returns raw true/false with --raw flag', () => {
238
+ const result = runAceTools('verify-path-exists nonexistent --raw', tmpDir);
239
+ assert.ok(result.success, `Command failed: ${result.error}`);
240
+ assert.strictEqual(result.output, 'false');
241
+ });
242
+
243
+ test('errors when no path provided', () => {
244
+ const result = runAceTools('verify-path-exists', tmpDir);
245
+ assert.strictEqual(result.success, false);
246
+ assert.ok(result.error.includes('path required'), `Expected error about path, got: ${result.error}`);
247
+ });
248
+ });
249
+
250
+ // ─── load-config ──────────────────────────────────────────────────────────────
251
+
252
+ describe('load-config command', () => {
253
+ let tmpDir;
254
+
255
+ beforeEach(() => {
256
+ tmpDir = createTempProject();
257
+ });
258
+
259
+ afterEach(() => {
260
+ cleanup(tmpDir);
261
+ });
262
+
263
+ test('returns defaults when no config file exists', () => {
264
+ const result = runAceTools('load-config', tmpDir);
265
+ assert.ok(result.success, `Command failed: ${result.error}`);
266
+ const config = JSON.parse(result.output);
267
+ assert.strictEqual(config.version, '0.1.0');
268
+ assert.strictEqual(config.projectName, '');
269
+ assert.strictEqual(config.storage, 'local');
270
+ assert.strictEqual(config.commit_docs, true);
271
+ assert.strictEqual(config.github.enabled, false);
272
+ assert.strictEqual(config.github.repo, null);
273
+ assert.strictEqual(config.github.labels.epic, 'ace:epic');
274
+ });
275
+
276
+ test('reads existing config and merges with defaults', () => {
277
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
278
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
279
+ projectName: 'Test Project',
280
+ storage: 'github',
281
+ github: { enabled: true, repo: 'owner/repo' },
282
+ }));
283
+
284
+ const result = runAceTools('load-config', tmpDir);
285
+ assert.ok(result.success, `Command failed: ${result.error}`);
286
+ const config = JSON.parse(result.output);
287
+ assert.strictEqual(config.projectName, 'Test Project');
288
+ assert.strictEqual(config.storage, 'github');
289
+ assert.strictEqual(config.github.enabled, true);
290
+ assert.strictEqual(config.github.repo, 'owner/repo');
291
+ // Defaults still applied for unset fields
292
+ assert.strictEqual(config.version, '0.1.0');
293
+ assert.strictEqual(config.github.labels.epic, 'ace:epic');
294
+ });
295
+
296
+ test('handles malformed JSON gracefully', () => {
297
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
298
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), 'not json');
299
+
300
+ const result = runAceTools('load-config', tmpDir);
301
+ assert.ok(result.success, `Command failed: ${result.error}`);
302
+ const config = JSON.parse(result.output);
303
+ // Should return defaults
304
+ assert.strictEqual(config.version, '0.1.0');
305
+ assert.strictEqual(config.projectName, '');
306
+ });
307
+ });
308
+
309
+ // ─── init new-project ─────────────────────────────────────────────────────────
310
+
311
+ describe('init new-project command', () => {
312
+ let tmpDir;
313
+
314
+ beforeEach(() => {
315
+ tmpDir = createTempProject();
316
+ });
317
+
318
+ afterEach(() => {
319
+ cleanup(tmpDir);
320
+ });
321
+
322
+ test('detects empty project (greenfield)', () => {
323
+ const result = runAceTools('init new-project', tmpDir);
324
+ assert.ok(result.success, `Command failed: ${result.error}`);
325
+ const data = JSON.parse(result.output);
326
+
327
+ assert.strictEqual(data.project_exists, false);
328
+ assert.strictEqual(data.has_codebase_map, false);
329
+ assert.strictEqual(data.planning_exists, false);
330
+ assert.strictEqual(data.is_brownfield, false);
331
+ assert.strictEqual(data.has_existing_code, false);
332
+ assert.strictEqual(data.has_package_file, false);
333
+ assert.strictEqual(data.needs_codebase_map, false);
334
+ assert.strictEqual(data.has_git, false);
335
+ assert.strictEqual(data.commit_docs, true);
336
+ });
337
+
338
+ test('detects existing code files (brownfield)', () => {
339
+ fs.writeFileSync(path.join(tmpDir, 'index.js'), 'console.log("hello");');
340
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
341
+
342
+ const result = runAceTools('init new-project', tmpDir);
343
+ assert.ok(result.success, `Command failed: ${result.error}`);
344
+ const data = JSON.parse(result.output);
345
+
346
+ assert.strictEqual(data.has_existing_code, true);
347
+ assert.strictEqual(data.has_package_file, true);
348
+ assert.strictEqual(data.is_brownfield, true);
349
+ assert.strictEqual(data.needs_codebase_map, true);
350
+ });
351
+
352
+ test('detects package file without code files', () => {
353
+ fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
354
+
355
+ const result = runAceTools('init new-project', tmpDir);
356
+ assert.ok(result.success, `Command failed: ${result.error}`);
357
+ const data = JSON.parse(result.output);
358
+
359
+ assert.strictEqual(data.has_existing_code, false);
360
+ assert.strictEqual(data.has_package_file, true);
361
+ assert.strictEqual(data.is_brownfield, true);
362
+ });
363
+
364
+ test('detects nested code files up to depth 3', () => {
365
+ const nested = path.join(tmpDir, 'src', 'lib', 'utils');
366
+ fs.mkdirSync(nested, { recursive: true });
367
+ fs.writeFileSync(path.join(nested, 'helper.ts'), 'export const x = 1;');
368
+
369
+ const result = runAceTools('init new-project', tmpDir);
370
+ assert.ok(result.success, `Command failed: ${result.error}`);
371
+ const data = JSON.parse(result.output);
372
+
373
+ assert.strictEqual(data.has_existing_code, true);
374
+ assert.strictEqual(data.needs_codebase_map, true);
375
+ });
376
+
377
+ test('ignores node_modules directory', () => {
378
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
379
+ fs.writeFileSync(path.join(tmpDir, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
380
+
381
+ const result = runAceTools('init new-project', tmpDir);
382
+ assert.ok(result.success, `Command failed: ${result.error}`);
383
+ const data = JSON.parse(result.output);
384
+
385
+ assert.strictEqual(data.has_existing_code, false);
386
+ assert.strictEqual(data.needs_codebase_map, false);
387
+ });
388
+
389
+ test('detects ACE already initialized', () => {
390
+ fs.mkdirSync(path.join(tmpDir, '.docs', 'product'), { recursive: true });
391
+ fs.writeFileSync(path.join(tmpDir, '.docs', 'product', 'product-vision.md'), '# My Product');
392
+
393
+ const result = runAceTools('init new-project', tmpDir);
394
+ assert.ok(result.success, `Command failed: ${result.error}`);
395
+ const data = JSON.parse(result.output);
396
+
397
+ assert.strictEqual(data.project_exists, true);
398
+ });
399
+
400
+ test('detects git repository', () => {
401
+ fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
402
+
403
+ const result = runAceTools('init new-project', tmpDir);
404
+ assert.ok(result.success, `Command failed: ${result.error}`);
405
+ const data = JSON.parse(result.output);
406
+
407
+ assert.strictEqual(data.has_git, true);
408
+ });
409
+
410
+ test('commit_docs defaults to true', () => {
411
+ const result = runAceTools('init new-project', tmpDir);
412
+ assert.ok(result.success, `Command failed: ${result.error}`);
413
+ const data = JSON.parse(result.output);
414
+
415
+ assert.strictEqual(data.commit_docs, true);
416
+ });
417
+
418
+ test('includes pre-resolved models for init agents', () => {
419
+ const result = runAceTools('init new-project', tmpDir);
420
+ assert.ok(result.success, `Command failed: ${result.error}`);
421
+ const data = JSON.parse(result.output);
422
+
423
+ assert.strictEqual(data.product_owner_model, 'opus');
424
+ assert.strictEqual(data.researcher_model, 'opus');
425
+ assert.strictEqual(data.synthesizer_model, 'sonnet');
426
+ });
427
+
428
+ test('models respect config profile', () => {
429
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
430
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
431
+ model_profile: 'budget',
432
+ }));
433
+
434
+ const result = runAceTools('init new-project', tmpDir);
435
+ assert.ok(result.success, `Command failed: ${result.error}`);
436
+ const data = JSON.parse(result.output);
437
+
438
+ assert.strictEqual(data.product_owner_model, 'sonnet');
439
+ assert.strictEqual(data.researcher_model, 'haiku');
440
+ assert.strictEqual(data.synthesizer_model, 'haiku');
441
+ });
442
+
443
+ test('detects Python project files', () => {
444
+ fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'flask==2.0');
445
+ fs.writeFileSync(path.join(tmpDir, 'app.py'), 'from flask import Flask');
446
+
447
+ const result = runAceTools('init new-project', tmpDir);
448
+ assert.ok(result.success, `Command failed: ${result.error}`);
449
+ const data = JSON.parse(result.output);
450
+
451
+ assert.strictEqual(data.has_existing_code, true);
452
+ assert.strictEqual(data.has_package_file, true);
453
+ assert.strictEqual(data.is_brownfield, true);
454
+ });
455
+
456
+ test('detects Go project files', () => {
457
+ fs.writeFileSync(path.join(tmpDir, 'go.mod'), 'module example.com/foo');
458
+ fs.writeFileSync(path.join(tmpDir, 'main.go'), 'package main');
459
+
460
+ const result = runAceTools('init new-project', tmpDir);
461
+ assert.ok(result.success, `Command failed: ${result.error}`);
462
+ const data = JSON.parse(result.output);
463
+
464
+ assert.strictEqual(data.has_existing_code, true);
465
+ assert.strictEqual(data.has_package_file, true);
466
+ });
467
+
468
+ test('detects Rust project files', () => {
469
+ fs.writeFileSync(path.join(tmpDir, 'Cargo.toml'), '[package]');
470
+ fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
471
+ fs.writeFileSync(path.join(tmpDir, 'src', 'main.rs'), 'fn main() {}');
472
+
473
+ const result = runAceTools('init new-project', tmpDir);
474
+ assert.ok(result.success, `Command failed: ${result.error}`);
475
+ const data = JSON.parse(result.output);
476
+
477
+ assert.strictEqual(data.has_existing_code, true);
478
+ assert.strictEqual(data.has_package_file, true);
479
+ });
480
+
481
+ test('needs_codebase_map is false when codebase dir exists', () => {
482
+ fs.mkdirSync(path.join(tmpDir, '.ace', 'codebase'), { recursive: true });
483
+ fs.writeFileSync(path.join(tmpDir, 'index.js'), 'console.log("hello");');
484
+
485
+ const result = runAceTools('init new-project', tmpDir);
486
+ assert.ok(result.success, `Command failed: ${result.error}`);
487
+ const data = JSON.parse(result.output);
488
+
489
+ assert.strictEqual(data.has_existing_code, true);
490
+ assert.strictEqual(data.has_codebase_map, true);
491
+ assert.strictEqual(data.needs_codebase_map, false);
492
+ });
493
+
494
+ test('commit_docs respects config override', () => {
495
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
496
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'config.json'), JSON.stringify({
497
+ commit_docs: false,
498
+ }));
499
+
500
+ const result = runAceTools('init new-project', tmpDir);
501
+ assert.ok(result.success, `Command failed: ${result.error}`);
502
+ const data = JSON.parse(result.output);
503
+
504
+ assert.strictEqual(data.commit_docs, false);
505
+ });
506
+
507
+ test('detects C# project files', () => {
508
+ fs.writeFileSync(path.join(tmpDir, 'App.cs'), 'namespace App {}');
509
+
510
+ const result = runAceTools('init new-project', tmpDir);
511
+ assert.ok(result.success, `Command failed: ${result.error}`);
512
+ const data = JSON.parse(result.output);
513
+
514
+ assert.strictEqual(data.has_existing_code, true);
515
+ assert.strictEqual(data.needs_codebase_map, true);
516
+ });
517
+
518
+ test('detects .csproj as package file', () => {
519
+ fs.writeFileSync(path.join(tmpDir, 'MyApp.csproj'), '<Project Sdk="Microsoft.NET.Sdk" />');
520
+
521
+ const result = runAceTools('init new-project', tmpDir);
522
+ assert.ok(result.success, `Command failed: ${result.error}`);
523
+ const data = JSON.parse(result.output);
524
+
525
+ assert.strictEqual(data.has_package_file, true);
526
+ assert.strictEqual(data.is_brownfield, true);
527
+ });
528
+
529
+ test('detects .sln as package file', () => {
530
+ fs.writeFileSync(path.join(tmpDir, 'MyApp.sln'), 'Microsoft Visual Studio Solution File');
531
+
532
+ const result = runAceTools('init new-project', tmpDir);
533
+ assert.ok(result.success, `Command failed: ${result.error}`);
534
+ const data = JSON.parse(result.output);
535
+
536
+ assert.strictEqual(data.has_package_file, true);
537
+ assert.strictEqual(data.is_brownfield, true);
538
+ });
539
+
540
+ test('detects Java project with build.gradle', () => {
541
+ fs.writeFileSync(path.join(tmpDir, 'build.gradle'), 'plugins {}');
542
+
543
+ const result = runAceTools('init new-project', tmpDir);
544
+ assert.ok(result.success, `Command failed: ${result.error}`);
545
+ const data = JSON.parse(result.output);
546
+
547
+ assert.strictEqual(data.has_package_file, true);
548
+ });
549
+
550
+ test('has_gh_cli is boolean', () => {
551
+ const result = runAceTools('init new-project', tmpDir);
552
+ assert.ok(result.success, `Command failed: ${result.error}`);
553
+ const data = JSON.parse(result.output);
554
+
555
+ assert.strictEqual(typeof data.has_gh_cli, 'boolean');
556
+ });
557
+ });
558
+
559
+ // ─── ensure-settings ──────────────────────────────────────────────────────────
560
+
561
+ describe('ensure-settings command', () => {
562
+ let tmpDir;
563
+
564
+ beforeEach(() => {
565
+ tmpDir = createTempProject();
566
+ });
567
+
568
+ afterEach(() => {
569
+ cleanup(tmpDir);
570
+ });
571
+
572
+ test('creates settings.json with defaults when missing', () => {
573
+ const result = runAceTools('ensure-settings', tmpDir);
574
+ assert.ok(result.success, `Command failed: ${result.error}`);
575
+ const data = JSON.parse(result.output);
576
+
577
+ assert.strictEqual(data.created, true);
578
+ assert.strictEqual(data.settings.model_profile, 'balanced');
579
+ assert.strictEqual(data.settings.commit_docs, true);
580
+ assert.strictEqual(data.settings.github_project.enabled, false);
581
+ assert.strictEqual(data.settings.github_project.gh_installed, false);
582
+ assert.strictEqual(data.settings.github_project.repo, '');
583
+ assert.strictEqual(data.settings.github_project.project_number, null);
584
+ assert.strictEqual(data.settings.github_project.owner, '');
585
+
586
+ // Verify file was actually created
587
+ const settingsPath = path.join(tmpDir, '.ace', 'settings.json');
588
+ assert.ok(fs.existsSync(settingsPath), 'settings.json should exist on disk');
589
+ const onDisk = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
590
+ assert.strictEqual(onDisk.model_profile, 'balanced');
591
+ });
592
+
593
+ test('creates .ace directory if it does not exist', () => {
594
+ const aceDir = path.join(tmpDir, '.ace');
595
+ assert.ok(!fs.existsSync(aceDir), '.ace dir should not exist yet');
596
+
597
+ const result = runAceTools('ensure-settings', tmpDir);
598
+ assert.ok(result.success, `Command failed: ${result.error}`);
599
+ const data = JSON.parse(result.output);
600
+
601
+ assert.strictEqual(data.created, true);
602
+ assert.ok(fs.existsSync(aceDir), '.ace dir should be created');
603
+ });
604
+
605
+ test('does not overwrite existing settings.json', () => {
606
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
607
+ const customSettings = {
608
+ model_profile: 'quality',
609
+ commit_docs: false,
610
+ github_project: {
611
+ enabled: true,
612
+ gh_installed: true,
613
+ repo: 'owner/repo',
614
+ project_number: 5,
615
+ owner: 'owner',
616
+ },
617
+ };
618
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'settings.json'), JSON.stringify(customSettings, null, 2));
619
+
620
+ const result = runAceTools('ensure-settings', tmpDir);
621
+ assert.ok(result.success, `Command failed: ${result.error}`);
622
+ const data = JSON.parse(result.output);
623
+
624
+ assert.strictEqual(data.created, false);
625
+ assert.strictEqual(data.settings.model_profile, 'quality');
626
+ assert.strictEqual(data.settings.commit_docs, false);
627
+ assert.strictEqual(data.settings.github_project.enabled, true);
628
+ assert.strictEqual(data.settings.github_project.project_number, 5);
629
+ });
630
+ });
631
+
632
+ // ─── init setup-github ────────────────────────────────────────────────────────
633
+
634
+ describe('init setup-github command', () => {
635
+ let tmpDir;
636
+
637
+ beforeEach(() => {
638
+ tmpDir = createTempProject();
639
+ });
640
+
641
+ afterEach(() => {
642
+ cleanup(tmpDir);
643
+ });
644
+
645
+ test('returns gh_installed as boolean', () => {
646
+ const result = runAceTools('init setup-github', tmpDir);
647
+ assert.ok(result.success, `Command failed: ${result.error}`);
648
+ const data = JSON.parse(result.output);
649
+
650
+ assert.strictEqual(typeof data.gh_installed, 'boolean');
651
+ });
652
+
653
+ test('returns projects as array', () => {
654
+ const result = runAceTools('init setup-github', tmpDir);
655
+ assert.ok(result.success, `Command failed: ${result.error}`);
656
+ const data = JSON.parse(result.output);
657
+
658
+ assert.ok(Array.isArray(data.projects), 'projects should be an array');
659
+ });
660
+
661
+ test('returns current_settings object', () => {
662
+ const result = runAceTools('init setup-github', tmpDir);
663
+ assert.ok(result.success, `Command failed: ${result.error}`);
664
+ const data = JSON.parse(result.output);
665
+
666
+ assert.ok(data.current_settings !== undefined, 'current_settings should be present');
667
+ assert.strictEqual(typeof data.current_settings.enabled, 'boolean');
668
+ });
669
+
670
+ test('returns repo and owner as strings', () => {
671
+ const result = runAceTools('init setup-github', tmpDir);
672
+ assert.ok(result.success, `Command failed: ${result.error}`);
673
+ const data = JSON.parse(result.output);
674
+
675
+ assert.strictEqual(typeof data.repo, 'string');
676
+ assert.strictEqual(typeof data.owner, 'string');
677
+ });
678
+ });
679
+
680
+ // ─── write-github-settings ────────────────────────────────────────────────────
681
+
682
+ describe('write-github-settings command', () => {
683
+ let tmpDir;
684
+
685
+ beforeEach(() => {
686
+ tmpDir = createTempProjectWithAce();
687
+ // Seed a settings.json with defaults
688
+ const defaults = {
689
+ model_profile: 'balanced',
690
+ commit_docs: true,
691
+ github_project: {
692
+ enabled: false,
693
+ gh_installed: false,
694
+ repo: '',
695
+ project_number: null,
696
+ owner: '',
697
+ },
698
+ };
699
+ fs.writeFileSync(path.join(tmpDir, '.ace', 'settings.json'), JSON.stringify(defaults, null, 2));
700
+ });
701
+
702
+ afterEach(() => {
703
+ cleanup(tmpDir);
704
+ });
705
+
706
+ test('writes key=value pairs to settings.json', () => {
707
+ const result = runAceTools('write-github-settings enabled=true repo=owner/repo project_number=3 owner=owner gh_installed=true', tmpDir);
708
+ assert.ok(result.success, `Command failed: ${result.error}`);
709
+ const data = JSON.parse(result.output);
710
+
711
+ assert.strictEqual(data.written, true);
712
+ assert.strictEqual(data.settings.github_project.enabled, true);
713
+ assert.strictEqual(data.settings.github_project.repo, 'owner/repo');
714
+ assert.strictEqual(data.settings.github_project.project_number, 3);
715
+ assert.strictEqual(data.settings.github_project.owner, 'owner');
716
+ assert.strictEqual(data.settings.github_project.gh_installed, true);
717
+
718
+ // Verify persisted to disk
719
+ const onDisk = JSON.parse(fs.readFileSync(path.join(tmpDir, '.ace', 'settings.json'), 'utf-8'));
720
+ assert.strictEqual(onDisk.github_project.enabled, true);
721
+ assert.strictEqual(onDisk.github_project.project_number, 3);
722
+ });
723
+
724
+ test('preserves non-github settings when writing', () => {
725
+ const result = runAceTools('write-github-settings enabled=true', tmpDir);
726
+ assert.ok(result.success, `Command failed: ${result.error}`);
727
+ const data = JSON.parse(result.output);
728
+
729
+ assert.strictEqual(data.settings.model_profile, 'balanced');
730
+ assert.strictEqual(data.settings.commit_docs, true);
731
+ });
732
+
733
+ test('handles project_number=null', () => {
734
+ // First set a number
735
+ runAceTools('write-github-settings project_number=5', tmpDir);
736
+ // Then reset to null
737
+ const result = runAceTools('write-github-settings project_number=null', tmpDir);
738
+ assert.ok(result.success, `Command failed: ${result.error}`);
739
+ const data = JSON.parse(result.output);
740
+
741
+ assert.strictEqual(data.settings.github_project.project_number, null);
742
+ });
743
+ });
744
+
745
+ // ─── init research-story ─────────────────────────────────────────────────────
746
+
747
+ const SAMPLE_STORY = `# S3: Display OAuth Provider Buttons
748
+
749
+ **Feature**: F3 OAuth2 Login Flow | **Epic**: #45 User Authentication
750
+ **Status**: Refined | **Size**: 3 | **Sprint**: Sprint 2 | **Link**: [#95](https://github.com/owner/repo/issues/95)
751
+
752
+ ## User Story
753
+
754
+ > As a returning customer,
755
+ > I want to click a Google or GitHub login button,
756
+ > so that I can authenticate without remembering a site-specific password.
757
+
758
+ ## Description
759
+
760
+ This story adds OAuth provider buttons to the login page. It builds on the
761
+ auth service foundation (S1) and enables the token exchange flow (S4).
762
+
763
+ ## Acceptance Criteria
764
+
765
+ ### Scenario: Successful Google login
766
+
767
+ **Given** the user is on the login page and has a valid Google account
768
+ **When** they click the "Sign in with Google" button and complete Google's OAuth flow
769
+ **Then** they are redirected to the dashboard and see their Google profile name
770
+
771
+ ### Scenario: Provider unavailable
772
+
773
+ **Given** the user is on the login page and the Google OAuth service is unreachable
774
+ **When** they click the "Sign in with Google" button
775
+ **Then** they see an error message "Login service temporarily unavailable. Please try again."
776
+
777
+ ### Scenario: GitHub login button displayed
778
+
779
+ **Given** the user navigates to the login page
780
+ **When** the page loads
781
+ **Then** they see a "Sign in with GitHub" button alongside the Google button
782
+
783
+ ## Out of Scope
784
+
785
+ - Token refresh logic (handled by S4)
786
+ - Account linking (future feature)
787
+
788
+ ## Dependencies
789
+
790
+ ### Blocked By
791
+ - S1 Auth service foundation
792
+
793
+ ### Blocks
794
+ - S4 Token exchange flow
795
+
796
+ ### External
797
+ - Google OAuth API — available
798
+
799
+ ## Definition of Done
800
+
801
+ - [ ] All acceptance criteria scenarios pass
802
+ - [ ] Code reviewed and approved
803
+ - [ ] Tests written and passing
804
+ - [ ] CI pipeline green
805
+ - [ ] Documentation updated (if applicable)
806
+ - [ ] Product Owner verified
807
+
808
+ ## Relevant Wiki
809
+
810
+ ### System-Wide
811
+
812
+ - \`.docs/wiki/system-wide/system-structure.md\` — Mandatory system-wide context
813
+ - \`.docs/wiki/system-wide/system-architecture.md\` — Mandatory system-wide context
814
+ - \`.docs/wiki/system-wide/coding-standards.md\` — Mandatory system-wide context
815
+ - \`.docs/wiki/system-wide/testing-framework.md\` — Mandatory system-wide context
816
+
817
+ ### Systems
818
+ - \`.docs/wiki/subsystems/auth/systems/oauth-provider.md\` — Implements the provider abstraction this story extends
819
+
820
+ ### Patterns
821
+ - \`.docs/wiki/subsystems/auth/patterns/strategy-pattern.md\` — Each OAuth provider is a strategy; new provider must follow this
822
+
823
+ ### Decisions
824
+ - \`.docs/wiki/subsystems/auth/decisions/adr-003-jwt-over-sessions.md\` — Constrains token format to JWT
825
+ `;
826
+
827
+ describe('init research-story command', () => {
828
+ let tmpDir;
829
+
830
+ beforeEach(() => {
831
+ tmpDir = createTempProject();
832
+ fs.mkdirSync(path.join(tmpDir, '.ace'), { recursive: true });
833
+ });
834
+
835
+ afterEach(() => {
836
+ cleanup(tmpDir);
837
+ });
838
+
839
+ test('parses story from file and extracts metadata', () => {
840
+ // Create story directory structure
841
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', '45-user-authentication', 'f3-oauth2-login-flow', 's3-display-oauth-provider-buttons');
842
+ fs.mkdirSync(storyDir, { recursive: true });
843
+ const storyFile = path.join(storyDir, 's3-display-oauth-provider-buttons.md');
844
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
845
+
846
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
847
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
848
+ assert.ok(result.success, `Command failed: ${result.error}`);
849
+ const data = JSON.parse(result.output);
850
+
851
+ assert.strictEqual(data.story_valid, true);
852
+ assert.strictEqual(data.story_error, null);
853
+ assert.strictEqual(data.story_source, 'file');
854
+ assert.strictEqual(data.story.id, 'S3');
855
+ assert.strictEqual(data.story.title, 'Display OAuth Provider Buttons');
856
+ assert.strictEqual(data.story.status, 'Refined');
857
+ assert.strictEqual(data.story.size, '3');
858
+ assert.strictEqual(data.feature.id, 'F3');
859
+ assert.strictEqual(data.feature.title, 'OAuth2 Login Flow');
860
+ assert.strictEqual(data.epic.id, '#45');
861
+ assert.strictEqual(data.epic.title, 'User Authentication');
862
+ });
863
+
864
+ test('extracts user story and description', () => {
865
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
866
+ fs.mkdirSync(storyDir, { recursive: true });
867
+ const storyFile = path.join(storyDir, 'story.md');
868
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
869
+
870
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
871
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
872
+ assert.ok(result.success, `Command failed: ${result.error}`);
873
+ const data = JSON.parse(result.output);
874
+
875
+ assert.ok(data.user_story.includes('As a returning customer'), `user_story should contain persona: ${data.user_story}`);
876
+ assert.ok(data.user_story.includes('authenticate without remembering'), `user_story should contain benefit`);
877
+ assert.ok(data.description.includes('OAuth provider buttons'), `description should contain story details: ${data.description}`);
878
+ assert.strictEqual(data.acceptance_criteria_count, 3);
879
+ });
880
+
881
+ test('extracts wiki references with categories', () => {
882
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
883
+ fs.mkdirSync(storyDir, { recursive: true });
884
+ const storyFile = path.join(storyDir, 'story.md');
885
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
886
+
887
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
888
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
889
+ assert.ok(result.success, `Command failed: ${result.error}`);
890
+ const data = JSON.parse(result.output);
891
+
892
+ assert.strictEqual(data.wiki_references.system_wide.length, 4);
893
+ assert.ok(data.wiki_references.system_wide.includes('.docs/wiki/system-wide/system-structure.md'));
894
+ assert.ok(data.wiki_references.system_wide.includes('.docs/wiki/system-wide/coding-standards.md'));
895
+
896
+ assert.strictEqual(data.wiki_references.subsystem_docs.length, 3);
897
+ const oauthDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('oauth-provider'));
898
+ assert.ok(oauthDoc, 'Should find oauth-provider doc');
899
+ assert.strictEqual(oauthDoc.category, 'systems');
900
+
901
+ const strategyDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('strategy-pattern'));
902
+ assert.ok(strategyDoc, 'Should find strategy-pattern doc');
903
+ assert.strictEqual(strategyDoc.category, 'patterns');
904
+
905
+ const adrDoc = data.wiki_references.subsystem_docs.find(d => d.path.includes('adr-003'));
906
+ assert.ok(adrDoc, 'Should find ADR doc');
907
+ assert.strictEqual(adrDoc.category, 'decisions');
908
+
909
+ assert.strictEqual(data.wiki_references.total_count, 7);
910
+ });
911
+
912
+ test('computes paths from file location', () => {
913
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'e1-auth', 'f3-oauth', 's3-buttons');
914
+ fs.mkdirSync(storyDir, { recursive: true });
915
+ const storyFile = path.join(storyDir, 's3-buttons.md');
916
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
917
+
918
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
919
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
920
+ assert.ok(result.success, `Command failed: ${result.error}`);
921
+ const data = JSON.parse(result.output);
922
+
923
+ assert.ok(data.paths, 'paths should be present');
924
+ assert.ok(data.paths.story_dir.includes('s3-buttons'), `story_dir should contain story slug: ${data.paths.story_dir}`);
925
+ assert.ok(data.paths.external_analysis_file.endsWith('external-analysis.md'), `external_analysis_file: ${data.paths.external_analysis_file}`);
926
+ assert.ok(data.paths.integration_analysis_file.endsWith('integration-analysis.md'));
927
+ assert.ok(data.paths.feature_file.endsWith('f3-oauth.md'), `feature_file: ${data.paths.feature_file}`);
928
+ });
929
+
930
+ test('detects existing artifacts', () => {
931
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
932
+ fs.mkdirSync(storyDir, { recursive: true });
933
+ const storyFile = path.join(storyDir, 'story.md');
934
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
935
+ // Create external analysis
936
+ fs.writeFileSync(path.join(storyDir, 'external-analysis.md'), '# External Analysis');
937
+
938
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
939
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
940
+ assert.ok(result.success, `Command failed: ${result.error}`);
941
+ const data = JSON.parse(result.output);
942
+
943
+ assert.strictEqual(data.has_external_analysis, true);
944
+ assert.strictEqual(data.has_integration_analysis, false);
945
+ });
946
+
947
+ test('verifies wiki doc existence', () => {
948
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
949
+ fs.mkdirSync(storyDir, { recursive: true });
950
+ const storyFile = path.join(storyDir, 'story.md');
951
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
952
+
953
+ // Create some wiki docs but not all
954
+ fs.mkdirSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide'), { recursive: true });
955
+ fs.writeFileSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide', 'system-structure.md'), '# Structure');
956
+ fs.writeFileSync(path.join(tmpDir, '.docs', 'wiki', 'system-wide', 'coding-standards.md'), '# Standards');
957
+
958
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
959
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
960
+ assert.ok(result.success, `Command failed: ${result.error}`);
961
+ const data = JSON.parse(result.output);
962
+
963
+ assert.ok(data.wiki_docs_exist.existing.length >= 2, `Should find at least 2 existing: ${JSON.stringify(data.wiki_docs_exist.existing)}`);
964
+ assert.ok(data.wiki_docs_exist.missing.length >= 2, `Should find at least 2 missing: ${JSON.stringify(data.wiki_docs_exist.missing)}`);
965
+ assert.ok(data.wiki_docs_exist.existing.includes('.docs/wiki/system-wide/system-structure.md'));
966
+ });
967
+
968
+ test('returns error for non-existent file', () => {
969
+ const result = runAceTools('init research-story nonexistent.md', tmpDir);
970
+ assert.ok(result.success, `Command should still succeed with error in JSON: ${result.error}`);
971
+ const data = JSON.parse(result.output);
972
+
973
+ assert.strictEqual(data.story_valid, false);
974
+ assert.ok(data.story_error.includes('not found'), `story_error: ${data.story_error}`);
975
+ });
976
+
977
+ test('returns error for no parameter', () => {
978
+ const result = runAceTools('init research-story', tmpDir);
979
+ assert.ok(result.success, `Command should still succeed with error in JSON: ${result.error}`);
980
+ const data = JSON.parse(result.output);
981
+
982
+ assert.strictEqual(data.story_valid, false);
983
+ assert.ok(data.story_error !== null, 'Should have a story_error');
984
+ });
985
+
986
+ test('includes model and environment fields', () => {
987
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
988
+ fs.mkdirSync(storyDir, { recursive: true });
989
+ const storyFile = path.join(storyDir, 'story.md');
990
+ fs.writeFileSync(storyFile, SAMPLE_STORY);
991
+
992
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
993
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
994
+ assert.ok(result.success, `Command failed: ${result.error}`);
995
+ const data = JSON.parse(result.output);
996
+
997
+ assert.ok(typeof data.analyst_model === 'string', 'analyst_model should be a string');
998
+ assert.ok(typeof data.mapper_model === 'string', 'mapper_model should be a string');
999
+ assert.ok(typeof data.has_git === 'boolean', 'has_git should be boolean');
1000
+ assert.ok(typeof data.has_gh_cli === 'boolean', 'has_gh_cli should be boolean');
1001
+ assert.ok(typeof data.commit_docs === 'boolean', 'commit_docs should be boolean');
1002
+ assert.ok(data.github_project !== undefined, 'github_project should be present');
1003
+ });
1004
+
1005
+ test('handles story without Relevant Wiki section', () => {
1006
+ const minimalStory = `# S1: Basic Story
1007
+
1008
+ **Feature**: F1 Auth | **Epic**: E1 Security
1009
+ **Status**: Todo | **Size**: 2
1010
+
1011
+ ## User Story
1012
+
1013
+ > As a user,
1014
+ > I want to log in,
1015
+ > so that I can access my account.
1016
+
1017
+ ## Description
1018
+
1019
+ Basic login functionality.
1020
+
1021
+ ## Acceptance Criteria
1022
+
1023
+ ### Scenario: Successful login
1024
+
1025
+ **Given** valid credentials
1026
+ **When** user submits login form
1027
+ **Then** user is redirected to dashboard
1028
+
1029
+ ## Definition of Done
1030
+
1031
+ - [ ] All AC pass
1032
+ `;
1033
+ const storyDir = path.join(tmpDir, '.ace', 'artifacts', 'product', 'epic', 'feat', 'story');
1034
+ fs.mkdirSync(storyDir, { recursive: true });
1035
+ const storyFile = path.join(storyDir, 'story.md');
1036
+ fs.writeFileSync(storyFile, minimalStory);
1037
+
1038
+ const relPath = path.relative(tmpDir, storyFile).replace(/\\/g, '/');
1039
+ const result = runAceTools(`init research-story ${relPath}`, tmpDir);
1040
+ assert.ok(result.success, `Command failed: ${result.error}`);
1041
+ const data = JSON.parse(result.output);
1042
+
1043
+ assert.strictEqual(data.story_valid, true);
1044
+ assert.strictEqual(data.wiki_references.total_count, 0);
1045
+ assert.strictEqual(data.acceptance_criteria_count, 1);
1046
+ assert.strictEqual(data.story.id, 'S1');
1047
+ });
1048
+
1049
+ test('classifies GitHub URL correctly', () => {
1050
+ // We can't actually fetch from GitHub in tests, but we can verify it tries
1051
+ const result = runAceTools('init research-story https://github.com/owner/repo/issues/123', tmpDir);
1052
+ assert.ok(result.success, `Command should succeed: ${result.error}`);
1053
+ const data = JSON.parse(result.output);
1054
+
1055
+ assert.strictEqual(data.story_source, 'github');
1056
+ // It will either fail due to no gh cli or fail to fetch — both are valid
1057
+ // The point is it classified correctly
1058
+ });
1059
+
1060
+ test('classifies issue number correctly', () => {
1061
+ const result = runAceTools('init research-story 42', tmpDir);
1062
+ assert.ok(result.success, `Command should succeed: ${result.error}`);
1063
+ const data = JSON.parse(result.output);
1064
+
1065
+ assert.strictEqual(data.story_source, 'github');
1066
+ });
1067
+ });
1068
+
1069
+ // ─── CLI error handling ───────────────────────────────────────────────────────
1070
+
1071
+ describe('CLI error handling', () => {
1072
+ test('errors on unknown command', () => {
1073
+ const result = runAceTools('nonexistent');
1074
+ assert.strictEqual(result.success, false);
1075
+ assert.ok(result.error.includes('Unknown command'), `Expected unknown command error, got: ${result.error}`);
1076
+ });
1077
+
1078
+ test('errors when no command provided', () => {
1079
+ const result = runAceTools('');
1080
+ assert.strictEqual(result.success, false);
1081
+ assert.ok(result.error.includes('Usage'), `Expected usage message, got: ${result.error}`);
1082
+ });
1083
+
1084
+ test('errors on unknown init subcommand', () => {
1085
+ const result = runAceTools('init nonexistent');
1086
+ assert.strictEqual(result.success, false);
1087
+ assert.ok(result.error.includes('Unknown init subcommand'), `Expected subcommand error, got: ${result.error}`);
1088
+ });
1089
+ });