@xulthekl/team-flow 0.23.0 → 0.25.1

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 (79) hide show
  1. package/.claude/always/phase-guard.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor-plugin/marketplace.json +1 -1
  6. package/.cursor-plugin/plugin.json +1 -1
  7. package/.github/plugin/marketplace.json +2 -2
  8. package/AGENTS.md +3 -3
  9. package/CHANGELOG.md +121 -0
  10. package/GEMINI.md +1 -1
  11. package/INSTALL.md +1 -1
  12. package/README.md +1 -1
  13. package/docs/README_en.md +1 -1
  14. package/docs/solutions/INDEX.md +6 -0
  15. package/docs/solutions/cross-phase/2026-07-28-no-summary.md +17 -0
  16. package/docs/solutions/cross-phase/2026-07-29-no-summary.md +17 -0
  17. package/gemini-extension.json +1 -1
  18. package/hooks/pre-tool-use-guard +9 -9
  19. package/hooks/session-start +2 -2
  20. package/llms.txt +1 -1
  21. package/package.json +1 -1
  22. package/plugin.json +1 -1
  23. package/scripts/guard/checks/compound-captured.mjs +70 -0
  24. package/scripts/guard/guard.mjs +3 -1
  25. package/scripts/lib/cmd-state.mjs +2 -0
  26. package/scripts/lib/config-loader.mjs +3 -0
  27. package/scripts/lib/solutions-promote.mjs +1 -1
  28. package/scripts/lib/state-loader.mjs +5 -0
  29. package/skills/bug-investigator/SKILL.md +1 -1
  30. package/skills/build-executor/SKILL.md +19 -19
  31. package/skills/build-executor/implementer-prompt.md +1 -1
  32. package/skills/build-executor/references/execution-modes.md +6 -6
  33. package/skills/build-executor/task-reviewer-prompt.md +1 -1
  34. package/skills/ce-brainstorm/SKILL.md +49 -348
  35. package/skills/ce-brainstorm/references/brainstorm-sections.md +11 -0
  36. package/skills/ce-brainstorm/references/grounding.md +47 -0
  37. package/skills/ce-brainstorm/references/output-format.md +25 -0
  38. package/skills/ce-brainstorm/references/phase0-routing.md +96 -0
  39. package/skills/ce-brainstorm/references/prd-mapping.md +37 -0
  40. package/skills/ce-brainstorm/references/prototype-loop.md +55 -0
  41. package/skills/ce-ideate/SKILL.md +31 -348
  42. package/skills/ce-ideate/references/grounding.md +107 -0
  43. package/skills/ce-ideate/references/phase0-scope.md +134 -0
  44. package/skills/ce-ideate/references/topic-surface.md +37 -0
  45. package/skills/ce-proof/SKILL.md +8 -266
  46. package/skills/ce-proof/references/api-recipes.md +145 -0
  47. package/skills/ce-proof/references/workflows.md +124 -0
  48. package/skills/code-reviewer/SKILL.md +2 -2
  49. package/skills/code-reviewer/code-reviewer-prompt.md +1 -1
  50. package/skills/contract-builder/SKILL.md +6 -6
  51. package/skills/need-explorer/SKILL.md +2 -2
  52. package/skills/prototype/SKILL.md +16 -2
  53. package/skills/prototype/references/agents/design-system-architect.md +11 -122
  54. package/skills/prototype/references/interactive-prototype.md +110 -0
  55. package/skills/prototype/references/layouts.md +188 -2
  56. package/skills/prototype/references/orchestration-flow.md +8 -1
  57. package/skills/prototype/references/template.html +32 -0
  58. package/skills/prototype/references/wireframe.md +117 -0
  59. package/skills/release-archivist/SKILL.md +14 -12
  60. package/skills/release-archivist/references/closing-procedures.md +11 -9
  61. package/skills/spec-merger/SKILL.md +2 -2
  62. package/skills/spec-writer/SKILL.md +3 -3
  63. package/skills/workflow-bootstrap/SKILL.md +13 -2
  64. package/skills/workflow-bootstrap/references/b1-reconnaissance.md +13 -7
  65. package/skills/workflow-bootstrap/scripts/recon-probe.sh +122 -1
  66. package/skills/workflow-orchestrator/references/s2-prd-prototype-loop.md +1 -1
  67. package/skills/workflow-orchestrator/references/s4-split-validate.md +1 -1
  68. package/skills/workflow-orchestrator/references/s5-monitoring.md +1 -1
  69. package/skills/workflow-start/SKILL.md +16 -16
  70. package/skills/workflow-start/references/routing-rules.md +17 -17
  71. package/templates/prd-brainstorm-profile.md +69 -0
  72. package/tests/lib/guard-compound-captured.test.mjs +92 -0
  73. package/tests/lib/guard-specs-merged.test.mjs +2 -0
  74. package/tests/lib/guard-tests-passing.test.mjs +2 -0
  75. package/tests/lib/guard.test.mjs +2 -0
  76. package/tests/lib/solutions-capture.test.mjs +108 -0
  77. package/tests/lib/solutions-index-gen.test.mjs +147 -0
  78. package/tests/lib/solutions-inject.test.mjs +115 -0
  79. package/tests/lib/solutions-promote.test.mjs +200 -0
@@ -0,0 +1,108 @@
1
+ // tests/lib/solutions-capture.test.mjs
2
+ // Tests for scripts/lib/solutions-capture.mjs — compound capture mechanism
3
+
4
+ import { describe, it, before, after, beforeEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname } from 'node:path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..', '..');
14
+
15
+ const { run: captureRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-capture.mjs'));
16
+
17
+ function makeDir() {
18
+ return mkdtempSync(join(tmpdir(), 'tf-sol-capture-'));
19
+ }
20
+
21
+ function cleanup(dir) {
22
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
23
+ }
24
+
25
+ describe('solutions-capture', () => {
26
+ let dir;
27
+
28
+ beforeEach(() => {
29
+ dir = makeDir();
30
+ });
31
+
32
+ after(() => {
33
+ // Clean up all temp dirs
34
+ cleanup(dir);
35
+ });
36
+
37
+ it('creates experience file in correct phase directory', () => {
38
+ const result = captureRun({
39
+ phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high',
40
+ summary: 'OAuth token refresh fails silently', dir,
41
+ });
42
+
43
+ assert.equal(result.phase, 'prd');
44
+ assert.equal(result.severity, 'high');
45
+ assert.match(result.file, /^prd\/\d{4}-\d{2}-\d{2}-.*\.md$/);
46
+
47
+ // Verify file exists and has frontmatter
48
+ const filePath = join(dir, result.file);
49
+ assert.ok(existsSync(filePath), 'experience file should exist');
50
+ const content = readFileSync(filePath, 'utf-8');
51
+ assert.match(content, /^---\nphase: prd\n/);
52
+ assert.match(content, /domain: auth/);
53
+ assert.match(content, /type: pitfall/);
54
+ assert.match(content, /severity: high/);
55
+ });
56
+
57
+ it('creates INDEX.md from scratch when missing', () => {
58
+ captureRun({
59
+ phase: 'build', domain: 'ci', type: 'pattern', severity: 'medium',
60
+ summary: 'Parallel test execution race condition', dir,
61
+ });
62
+
63
+ const indexPath = join(dir, 'INDEX.md');
64
+ assert.ok(existsSync(indexPath), 'INDEX.md should be created');
65
+ const content = readFileSync(indexPath, 'utf-8');
66
+ assert.match(content, /# Solutions Index/);
67
+ assert.match(content, /parallel test execution/i);
68
+ });
69
+
70
+ it('appends to existing INDEX.md', () => {
71
+ captureRun({ phase: 'build', domain: 'ci', type: 'pattern', severity: 'high', summary: 'First issue', dir });
72
+ captureRun({ phase: 'spec', domain: 'api', type: 'insight', severity: 'low', summary: 'Second issue', dir });
73
+
74
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
75
+ assert.match(content, /First issue/);
76
+ assert.match(content, /Second issue/);
77
+ });
78
+
79
+ it('handles Chinese characters in summary (slugify)', () => {
80
+ const result = captureRun({
81
+ phase: 'review', domain: 'frontend', type: 'pitfall', severity: 'high',
82
+ summary: '中文标题测试特殊字符', dir,
83
+ });
84
+
85
+ assert.ok(result.file.includes('review/'), 'file should be in review phase');
86
+ assert.ok(existsSync(join(dir, result.file)), 'file with Chinese slug should exist');
87
+ });
88
+
89
+ it('uses cross-phase as default when no phase specified', () => {
90
+ const result = captureRun({ dir });
91
+ assert.equal(result.phase, 'cross-phase');
92
+ assert.equal(result.domain, 'general');
93
+ assert.equal(result.severity, 'medium');
94
+ });
95
+
96
+ it('rejects invalid phase', () => {
97
+ // capture calls process.exit(1) on invalid phase
98
+ // We test by checking that the function does not create files
99
+ const origExit = process.exit;
100
+ let exitCode = null;
101
+ process.exit = (code) => { exitCode = code; };
102
+ try {
103
+ captureRun({ phase: 'invalid-phase', dir });
104
+ } catch { /* ignore */ }
105
+ process.exit = origExit;
106
+ assert.equal(exitCode, 1, 'should exit with code 1 for invalid phase');
107
+ });
108
+ });
@@ -0,0 +1,147 @@
1
+ // tests/lib/solutions-index-gen.test.mjs
2
+ // Tests for scripts/lib/solutions-index-gen.mjs — full INDEX.md rebuild
3
+
4
+ import { describe, it, before, after, beforeEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname } from 'node:path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..', '..');
14
+
15
+ const { run: indexGenRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-index-gen.mjs'));
16
+
17
+ function makeDir() {
18
+ return mkdtempSync(join(tmpdir(), 'tf-sol-indexgen-'));
19
+ }
20
+
21
+ function cleanup(dir) {
22
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
23
+ }
24
+
25
+ function writeEntry(dir, phase, filename, fm) {
26
+ const phaseDir = join(dir, phase);
27
+ mkdirSync(phaseDir, { recursive: true });
28
+ const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join('\n');
29
+ writeFileSync(join(phaseDir, filename), `---\n${fmLines}\n---\n\n${fm.summary || 'Body text'}\n`, 'utf-8');
30
+ }
31
+
32
+ describe('solutions-index-gen', () => {
33
+ let dir;
34
+
35
+ beforeEach(() => { dir = makeDir(); });
36
+ after(() => { cleanup(dir); });
37
+
38
+ it('rebuilds INDEX.md from phase directories', () => {
39
+ writeEntry(dir, 'prd', '2026-07-01-auth-issue.md', {
40
+ phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-01',
41
+ });
42
+ writeEntry(dir, 'build', '2026-07-02-ci-pattern.md', {
43
+ phase: 'build', domain: 'ci', type: 'pattern', severity: 'medium', date: '2026-07-02',
44
+ });
45
+
46
+ indexGenRun({ dir });
47
+
48
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
49
+ assert.match(content, /# Solutions Index/);
50
+ assert.match(content, /auth-issue/);
51
+ assert.match(content, /ci-pattern/);
52
+ });
53
+
54
+ it('sorts by severity descending (high before medium before low)', () => {
55
+ writeEntry(dir, 'prd', 'low.md', {
56
+ phase: 'prd', domain: 'd1', type: 'pitfall', severity: 'low', date: '2026-07-03',
57
+ });
58
+ writeEntry(dir, 'prd', 'high.md', {
59
+ phase: 'prd', domain: 'd2', type: 'pitfall', severity: 'high', date: '2026-07-01',
60
+ });
61
+ writeEntry(dir, 'prd', 'medium.md', {
62
+ phase: 'prd', domain: 'd3', type: 'pitfall', severity: 'medium', date: '2026-07-02',
63
+ });
64
+
65
+ indexGenRun({ dir });
66
+
67
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
68
+ const highPos = content.indexOf('d2');
69
+ const medPos = content.indexOf('d3');
70
+ const lowPos = content.indexOf('d1');
71
+ assert.ok(highPos < medPos, 'high should appear before medium');
72
+ assert.ok(medPos < lowPos, 'medium should appear before low');
73
+ });
74
+
75
+ it('sorts by date descending within same severity', () => {
76
+ writeEntry(dir, 'prd', 'older.md', {
77
+ phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-01',
78
+ });
79
+ writeEntry(dir, 'prd', 'newer.md', {
80
+ phase: 'prd', domain: 'auth', type: 'pitfall', severity: 'high', date: '2026-07-15',
81
+ });
82
+
83
+ indexGenRun({ dir });
84
+
85
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
86
+ const newerPos = content.indexOf('2026-07-15');
87
+ const olderPos = content.indexOf('2026-07-01');
88
+ assert.ok(newerPos < olderPos, 'newer date should appear before older date');
89
+ });
90
+
91
+ it('scans all 7 phase directories', () => {
92
+ const phases = ['prd', 'plan', 'prototype', 'spec', 'build', 'review', 'cross-phase'];
93
+ for (const phase of phases) {
94
+ writeEntry(dir, phase, `2026-07-01-${phase}-entry.md`, {
95
+ phase, domain: 'general', type: 'insight', severity: 'medium', date: '2026-07-01',
96
+ });
97
+ }
98
+
99
+ indexGenRun({ dir });
100
+
101
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
102
+ for (const phase of phases) {
103
+ assert.match(content, new RegExp(phase), `INDEX should contain ${phase} entries`);
104
+ }
105
+ });
106
+
107
+ it('skips non-.md files', () => {
108
+ mkdirSync(join(dir, 'build'), { recursive: true });
109
+ writeFileSync(join(dir, 'build', 'readme.txt'), 'Not a markdown file', 'utf-8');
110
+ writeEntry(dir, 'build', '2026-07-01-real.md', {
111
+ phase: 'build', domain: 'ci', type: 'pattern', severity: 'high', date: '2026-07-01',
112
+ });
113
+
114
+ indexGenRun({ dir });
115
+
116
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
117
+ assert.doesNotMatch(content, /readme\.txt/);
118
+ assert.match(content, /real\.md/);
119
+ });
120
+
121
+ it('truncates to 150 entries (drops lowest priority)', () => {
122
+ // Create 155 entries (all high severity, varying dates)
123
+ mkdirSync(join(dir, 'build'), { recursive: true });
124
+ for (let i = 0; i < 155; i++) {
125
+ const day = String((i % 28) + 1).padStart(2, '0');
126
+ writeEntry(dir, 'build', `entry-${String(i).padStart(3, '0')}.md`, {
127
+ phase: 'build', domain: `d${i}`, type: 'pattern', severity: 'high', date: `2026-07-${day}`,
128
+ });
129
+ }
130
+
131
+ indexGenRun({ dir });
132
+
133
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
134
+ const dataLines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
135
+ assert.equal(dataLines.length, 150, 'INDEX should be truncated to 150 entries');
136
+ });
137
+
138
+ it('handles empty solutions directory', () => {
139
+ // No phase directories exist
140
+ indexGenRun({ dir });
141
+
142
+ const content = readFileSync(join(dir, 'INDEX.md'), 'utf-8');
143
+ assert.match(content, /# Solutions Index/);
144
+ const dataLines = content.split('\n').filter(l => l.startsWith('|') && !l.startsWith('| date') && !l.startsWith('|--'));
145
+ assert.equal(dataLines.length, 0);
146
+ });
147
+ });
@@ -0,0 +1,115 @@
1
+ // tests/lib/solutions-inject.test.mjs
2
+ // Tests for scripts/lib/solutions-inject.mjs — compound injection mechanism
3
+
4
+ import { describe, it, before, after, beforeEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname } from 'node:path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..', '..');
14
+
15
+ const { run: injectRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-inject.mjs'));
16
+
17
+ function makeDir() {
18
+ return mkdtempSync(join(tmpdir(), 'tf-sol-inject-'));
19
+ }
20
+
21
+ function cleanup(dir) {
22
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
23
+ }
24
+
25
+ const INDEX_HEADER = `# Solutions Index
26
+ <!-- 每条一行,按 severity 降序,≤150 行硬上限 -->
27
+ | date | phase | domain | type | severity | summary | file |
28
+ |------|-------|--------|------|----------|---------|------|
29
+ `;
30
+
31
+ function writeIndex(dir, rows) {
32
+ mkdirSync(dir, { recursive: true });
33
+ writeFileSync(join(dir, 'INDEX.md'), INDEX_HEADER + rows.join('\n') + '\n', 'utf-8');
34
+ }
35
+
36
+ describe('solutions-inject', () => {
37
+ let dir;
38
+
39
+ beforeEach(() => { dir = makeDir(); });
40
+ after(() => { cleanup(dir); });
41
+
42
+ it('returns empty when no INDEX.md exists', () => {
43
+ const result = injectRun({ phase: 'prd', dir });
44
+ assert.deepEqual(result.entries, []);
45
+ });
46
+
47
+ it('filters by exact phase match', () => {
48
+ writeIndex(dir, [
49
+ '| 2026-07-01 | prd | auth | pitfall | high | PRD auth issue | prd/f1.md |',
50
+ '| 2026-07-01 | build | auth | pitfall | high | Build auth issue | build/f2.md |',
51
+ ]);
52
+ const result = injectRun({ phase: 'prd', dir });
53
+ assert.equal(result.entries.length, 1);
54
+ assert.equal(result.entries[0].summary, 'PRD auth issue');
55
+ });
56
+
57
+ it('includes cross-phase entries for any phase query', () => {
58
+ writeIndex(dir, [
59
+ '| 2026-07-01 | cross-phase | general | pattern | high | Global pattern | cross-phase/f1.md |',
60
+ '| 2026-07-01 | prd | auth | pitfall | high | PRD issue | prd/f2.md |',
61
+ ]);
62
+ const result = injectRun({ phase: 'build', dir });
63
+ // cross-phase matches for any phase
64
+ assert.equal(result.entries.length, 1);
65
+ assert.equal(result.entries[0].phase, 'cross-phase');
66
+ });
67
+
68
+ it('filters by domain (exact match)', () => {
69
+ writeIndex(dir, [
70
+ '| 2026-07-01 | prd | auth | pitfall | high | Auth issue | prd/f1.md |',
71
+ '| 2026-07-01 | prd | billing | pitfall | high | Billing issue | prd/f2.md |',
72
+ ]);
73
+ const result = injectRun({ phase: 'prd', domain: 'auth', dir });
74
+ assert.equal(result.entries.length, 1);
75
+ assert.equal(result.entries[0].domain, 'auth');
76
+ });
77
+
78
+ it('domain "general" matches any query', () => {
79
+ writeIndex(dir, [
80
+ '| 2026-07-01 | prd | general | pattern | high | General pattern | prd/f1.md |',
81
+ ]);
82
+ const result = injectRun({ phase: 'prd', domain: 'auth', dir });
83
+ assert.equal(result.entries.length, 1);
84
+ });
85
+
86
+ it('sorts by severity descending (high > medium > low)', () => {
87
+ writeIndex(dir, [
88
+ '| 2026-07-01 | prd | auth | pitfall | low | Low issue | prd/f1.md |',
89
+ '| 2026-07-01 | prd | auth | pitfall | high | High issue | prd/f2.md |',
90
+ '| 2026-07-01 | prd | auth | pitfall | medium | Medium issue | prd/f3.md |',
91
+ ]);
92
+ const result = injectRun({ phase: 'prd', dir });
93
+ assert.equal(result.entries[0].severity, 'high');
94
+ assert.equal(result.entries[1].severity, 'medium');
95
+ assert.equal(result.entries[2].severity, 'low');
96
+ });
97
+
98
+ it('limits to top-5 entries', () => {
99
+ const rows = [];
100
+ for (let i = 0; i < 10; i++) {
101
+ rows.push(`| 2026-07-0${(i % 9) + 1} | prd | auth | pitfall | high | Issue ${i} | prd/f${i}.md |`);
102
+ }
103
+ writeIndex(dir, rows);
104
+ const result = injectRun({ phase: 'prd', dir });
105
+ assert.equal(result.entries.length, 5, 'should cap at top-5');
106
+ });
107
+
108
+ it('returns empty for no matching entries', () => {
109
+ writeIndex(dir, [
110
+ '| 2026-07-01 | build | ci | pattern | high | Build issue | build/f1.md |',
111
+ ]);
112
+ const result = injectRun({ phase: 'prd', dir });
113
+ assert.deepEqual(result.entries, []);
114
+ });
115
+ });
@@ -0,0 +1,200 @@
1
+ // tests/lib/solutions-promote.test.mjs
2
+ // Tests for scripts/lib/solutions-promote.mjs — change→product promotion
3
+
4
+ import { describe, it, before, after, beforeEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname } from 'node:path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const ROOT = join(__dirname, '..', '..');
14
+
15
+ // NODE_ENV=test is set below before import — promote's CLI guard skips when test.
16
+ process.env.NODE_ENV = 'test';
17
+ const { run: promoteRun } = await import(join(ROOT, 'scripts', 'lib', 'solutions-promote.mjs'));
18
+
19
+ function makeDir(prefix = '') {
20
+ return mkdtempSync(join(tmpdir(), `tf-sol-promote-${prefix}-`));
21
+ }
22
+
23
+ function cleanup(dir) {
24
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
25
+ }
26
+
27
+ function writeLearnings(dir, content) {
28
+ writeFileSync(join(dir, 'learnings.md'), content, 'utf-8');
29
+ }
30
+
31
+ const INDEX_HEADER = `# Solutions Index
32
+ | date | phase | domain | type | severity | summary | file |
33
+ |------|-------|--------|------|----------|---------|------|
34
+ `;
35
+
36
+ function writeGlobalIndex(solDir, rows = []) {
37
+ mkdirSync(solDir, { recursive: true });
38
+ writeFileSync(join(solDir, 'INDEX.md'), INDEX_HEADER + rows.join('\n') + '\n', 'utf-8');
39
+ }
40
+
41
+ describe('solutions-promote', () => {
42
+ let changeDir, solDir;
43
+
44
+ beforeEach(() => {
45
+ changeDir = makeDir('change');
46
+ solDir = makeDir('solutions');
47
+ });
48
+
49
+ after(() => {
50
+ cleanup(changeDir);
51
+ cleanup(solDir);
52
+ });
53
+
54
+ it('returns zero when no learnings.md exists', () => {
55
+ const result = promoteRun({ changeDir, dir: solDir });
56
+ assert.equal(result.promoted, 0);
57
+ assert.equal(result.updated, 0);
58
+ });
59
+
60
+ it('promotes entry with severity=high and type=pitfall', () => {
61
+ writeLearnings(changeDir, `## Auth token refresh failure
62
+ ---
63
+ phase: build
64
+ domain: auth
65
+ type: pitfall
66
+ severity: high
67
+ date: 2026-07-15
68
+ ---
69
+
70
+ When OAuth token refresh fails, the system silently swallows the error.
71
+ `);
72
+ const result = promoteRun({ changeDir, dir: solDir });
73
+ assert.equal(result.promoted, 1);
74
+ assert.ok(existsSync(join(solDir, 'build')), 'build phase dir should exist');
75
+ });
76
+
77
+ it('promotes entry with severity=medium and type=pattern', () => {
78
+ writeLearnings(changeDir, `## Retry with backoff pattern
79
+ ---
80
+ phase: build
81
+ domain: networking
82
+ type: pattern
83
+ severity: medium
84
+ date: 2026-07-15
85
+ ---
86
+
87
+ Use exponential backoff for API retry logic.
88
+ `);
89
+ const result = promoteRun({ changeDir, dir: solDir });
90
+ assert.equal(result.promoted, 1);
91
+ });
92
+
93
+ it('skips entry with severity=low', () => {
94
+ writeLearnings(changeDir, `## Minor formatting issue
95
+ ---
96
+ phase: build
97
+ domain: style
98
+ type: pitfall
99
+ severity: low
100
+ date: 2026-07-15
101
+ ---
102
+
103
+ Some low severity issue.
104
+ `);
105
+ const result = promoteRun({ changeDir, dir: solDir });
106
+ assert.equal(result.promoted, 0);
107
+ });
108
+
109
+ it('skips entry with type=insight (not pitfall/pattern)', () => {
110
+ writeLearnings(changeDir, `## Interesting observation
111
+ ---
112
+ phase: build
113
+ domain: auth
114
+ type: insight
115
+ severity: high
116
+ date: 2026-07-15
117
+ ---
118
+
119
+ An insight that should not be promoted.
120
+ `);
121
+ const result = promoteRun({ changeDir, dir: solDir });
122
+ assert.equal(result.promoted, 0);
123
+ });
124
+
125
+ it('detects duplicate domain+type and marks as confirmed', () => {
126
+ writeGlobalIndex(solDir, [
127
+ '| 2026-07-01 | build | auth | pitfall | medium | Existing auth pitfall | build/existing.md |',
128
+ ]);
129
+ writeLearnings(changeDir, `## Another auth pitfall
130
+ ---
131
+ phase: build
132
+ domain: auth
133
+ type: pitfall
134
+ severity: high
135
+ date: 2026-07-15
136
+ ---
137
+
138
+ Duplicate domain+type with existing index entry.
139
+ `);
140
+ const result = promoteRun({ changeDir, dir: solDir });
141
+ assert.equal(result.updated, 1, 'should detect duplicate and mark as confirmed');
142
+ assert.equal(result.promoted, 0, 'should not create new file for duplicate');
143
+ });
144
+
145
+ it('promotes multiple entries from single learnings.md', () => {
146
+ writeLearnings(changeDir, `## Issue one
147
+ ---
148
+ phase: spec
149
+ domain: api
150
+ type: pitfall
151
+ severity: high
152
+ date: 2026-07-15
153
+ ---
154
+
155
+ First pitfall.
156
+
157
+ ## Issue two
158
+ ---
159
+ phase: spec
160
+ domain: db
161
+ type: pattern
162
+ severity: medium
163
+ date: 2026-07-15
164
+ ---
165
+
166
+ Second pattern.
167
+
168
+ ## Issue three (low sev, skipped)
169
+ ---
170
+ phase: spec
171
+ domain: style
172
+ type: pitfall
173
+ severity: low
174
+ date: 2026-07-15
175
+ ---
176
+
177
+ Skipped due to low severity.
178
+ `);
179
+ const result = promoteRun({ changeDir, dir: solDir });
180
+ assert.equal(result.promoted, 2, 'should promote 2 entries (high+medium, skip low)');
181
+ });
182
+
183
+ it('appends promoted entry to global INDEX.md', () => {
184
+ writeGlobalIndex(solDir);
185
+ writeLearnings(changeDir, `## DB migration ordering
186
+ ---
187
+ phase: build
188
+ domain: db
189
+ type: pitfall
190
+ severity: high
191
+ date: 2026-07-15
192
+ ---
193
+
194
+ Migration files must be ordered by timestamp.
195
+ `);
196
+ promoteRun({ changeDir, dir: solDir });
197
+ const indexContent = readFileSync(join(solDir, 'INDEX.md'), 'utf-8');
198
+ assert.match(indexContent, /DB migration ordering/);
199
+ });
200
+ });