sentix 2.0.21 → 2.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentix",
3
- "version": "2.0.21",
3
+ "version": "2.1.0",
4
4
  "description": "Autonomous multi-agent DevSecOps pipeline CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,14 +20,29 @@ const sentixRoot = resolve(__dirname, '..', '..');
20
20
 
21
21
  // 동기화 대상: 프레임워크 공통 파일 (모든 프로젝트가 동일해야 하는 것)
22
22
  const SYNC_FILES = [
23
+ // CI/CD
23
24
  { src: '.github/workflows/deploy.yml', dst: '.github/workflows/deploy.yml' },
24
25
  { src: '.github/workflows/security-scan.yml', dst: '.github/workflows/security-scan.yml' },
26
+
27
+ // 하드 룰 + 검증
25
28
  { src: '.sentix/rules/hard-rules.md', dst: '.sentix/rules/hard-rules.md' },
29
+ { src: 'scripts/pre-commit.js', dst: 'scripts/pre-commit.js' },
30
+
31
+ // 프레임워크 문서
26
32
  { src: 'FRAMEWORK.md', dst: 'FRAMEWORK.md' },
27
33
  { src: 'docs/governor-sop.md', dst: 'docs/governor-sop.md' },
28
34
  { src: 'docs/agent-scopes.md', dst: 'docs/agent-scopes.md' },
35
+ { src: 'docs/agent-methods.md', dst: 'docs/agent-methods.md' },
29
36
  { src: 'docs/severity.md', dst: 'docs/severity.md' },
30
37
  { src: 'docs/architecture.md', dst: 'docs/architecture.md' },
38
+
39
+ // Claude Code 네이티브 에이전트
40
+ { src: '.claude/settings.json', dst: '.claude/settings.json' },
41
+ { src: '.claude/agents/planner.md', dst: '.claude/agents/planner.md' },
42
+ { src: '.claude/agents/dev.md', dst: '.claude/agents/dev.md' },
43
+ { src: '.claude/agents/pr-review.md', dst: '.claude/agents/pr-review.md' },
44
+ { src: '.claude/agents/dev-fix.md', dst: '.claude/agents/dev-fix.md' },
45
+ { src: '.claude/agents/security.md', dst: '.claude/agents/security.md' },
31
46
  ];
32
47
 
33
48
  registerCommand('update', {
@@ -6,14 +6,14 @@
6
6
  * sentix version changelog — CHANGELOG 미리보기 생성
7
7
  */
8
8
 
9
- import { spawnSync } from 'node:child_process';
9
+ import { spawnSync, execSync } from 'node:child_process';
10
10
  import { registerCommand } from '../registry.js';
11
11
  import { parseSemver, bumpSemver } from '../lib/semver.js';
12
- import { generateForVersion, prependToChangelog } from '../lib/changelog.js';
12
+ import { generateForVersion, prependToChangelog, detectBumpType } from '../lib/changelog.js';
13
13
 
14
14
  registerCommand('version', {
15
15
  description: 'Manage project version (bump | current | changelog)',
16
- usage: 'sentix version <bump|current|changelog> [major|minor|patch]',
16
+ usage: 'sentix version <bump|current|changelog> [auto|major|minor|patch]',
17
17
 
18
18
  async run(args, ctx) {
19
19
  const subcommand = args[0];
@@ -21,9 +21,13 @@ registerCommand('version', {
21
21
  if (!subcommand || subcommand === 'current') {
22
22
  await showCurrent(ctx);
23
23
  } else if (subcommand === 'bump') {
24
- const type = args[1] || 'patch';
24
+ let type = args[1] || 'auto';
25
+ if (type === 'auto') {
26
+ type = autoDetectBumpType(ctx);
27
+ ctx.log(`Auto-detected bump type: ${type}\n`);
28
+ }
25
29
  if (!['major', 'minor', 'patch'].includes(type)) {
26
- ctx.error(`Invalid bump type: ${type} (use major|minor|patch)`);
30
+ ctx.error(`Invalid bump type: ${type} (use auto|major|minor|patch)`);
27
31
  return;
28
32
  }
29
33
  await bumpVersion(type, ctx);
@@ -31,11 +35,37 @@ registerCommand('version', {
31
35
  await showChangelog(ctx);
32
36
  } else {
33
37
  ctx.error(`Unknown subcommand: ${subcommand}`);
34
- ctx.log('Usage: sentix version <bump|current|changelog> [major|minor|patch]');
38
+ ctx.log('Usage: sentix version <bump|current|changelog> [auto|major|minor|patch]');
35
39
  }
36
40
  },
37
41
  });
38
42
 
43
+ // ── Auto-detect bump type from commits ───────────────
44
+
45
+ function autoDetectBumpType(ctx) {
46
+ try {
47
+ // Get commits since last tag
48
+ let range = '';
49
+ try {
50
+ const lastTag = execSync('git describe --tags --abbrev=0 HEAD~1 2>/dev/null', {
51
+ cwd: ctx.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
52
+ }).trim();
53
+ if (lastTag) range = `${lastTag}..HEAD`;
54
+ } catch {
55
+ range = 'HEAD~20..HEAD';
56
+ }
57
+
58
+ const log = execSync(`git log ${range} --pretty=format:"%s" 2>/dev/null`, {
59
+ cwd: ctx.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
60
+ }).trim();
61
+
62
+ const messages = log ? log.split('\n') : [];
63
+ return detectBumpType(messages);
64
+ } catch {
65
+ return 'patch';
66
+ }
67
+ }
68
+
39
69
  // ── sentix version current ────────────────────────────
40
70
 
41
71
  async function showCurrent(ctx) {
@@ -1,62 +1,173 @@
1
1
  /**
2
- * CHANGELOG.md auto-generation from governor history and ticket index.
2
+ * CHANGELOG.md auto-generation from git commits + ticket index.
3
3
  *
4
- * Matches existing format:
4
+ * Format:
5
5
  * ## [x.y.z] — YYYY-MM-DD
6
6
  * ### Category
7
7
  * - entry
8
+ *
9
+ * Commit convention:
10
+ * feat: → New Features
11
+ * fix: → Bug Fixes
12
+ * ci: → CI/CD
13
+ * docs: → Documentation
14
+ * chore: → Improvements
15
+ * refactor: → Improvements
8
16
  */
9
17
 
18
+ import { execSync } from 'node:child_process';
10
19
  import { loadIndex } from './ticket-index.js';
11
20
 
12
21
  /**
13
- * Build changelog entries from resolved tickets since last version.
22
+ * Parse conventional commit messages from git log since last tag.
14
23
  */
15
- export async function buildFromTickets(ctx) {
16
- const entries = await loadIndex(ctx);
17
- const resolved = entries.filter(e =>
18
- e.status === 'resolved' || e.status === 'closed'
19
- );
24
+ function getCommitsSinceLastTag(cwd) {
25
+ // Find last tag
26
+ let range = '';
27
+ try {
28
+ const lastTag = execSync('git describe --tags --abbrev=0 HEAD~1 2>/dev/null', {
29
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+ if (lastTag) range = `${lastTag}..HEAD`;
32
+ } catch {
33
+ // No previous tag — use all commits (limit 50)
34
+ range = 'HEAD~50..HEAD';
35
+ }
36
+
37
+ try {
38
+ const log = execSync(`git log ${range} --pretty=format:"%s" 2>/dev/null`, {
39
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
40
+ }).trim();
41
+ return log ? log.split('\n') : [];
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
20
46
 
47
+ /**
48
+ * Categorize commit messages by conventional commit prefix.
49
+ */
50
+ function categorizeCommits(messages) {
21
51
  const categories = {
22
52
  'New Features': [],
23
53
  'Bug Fixes': [],
24
54
  'Security Fixes': [],
55
+ 'CI/CD': [],
56
+ 'Documentation': [],
25
57
  'Improvements': [],
26
58
  };
27
59
 
28
- for (const ticket of resolved) {
29
- const line = `- \`${ticket.id}\`: ${ticket.title}`;
30
- if (ticket.type === 'feature') {
31
- categories['New Features'].push(line);
32
- } else if (ticket.severity === 'critical' && ticket.title.toLowerCase().includes('security')) {
33
- categories['Security Fixes'].push(line);
34
- } else if (ticket.type === 'bug') {
35
- categories['Bug Fixes'].push(line);
36
- } else {
37
- categories['Improvements'].push(line);
60
+ for (const msg of messages) {
61
+ // Skip version bump commits
62
+ if (msg.startsWith('chore: bump version')) continue;
63
+ if (msg.startsWith('Merge pull request')) continue;
64
+
65
+ const cleaned = msg.replace(/^"(.*)"$/, '$1');
66
+
67
+ if (/^feat(\(.+?\))?:\s/.test(cleaned)) {
68
+ categories['New Features'].push(`- ${cleaned.replace(/^feat(\(.+?\))?:\s*/, '')}`);
69
+ } else if (/^fix(\(.+?\))?:\s/.test(cleaned)) {
70
+ categories['Bug Fixes'].push(`- ${cleaned.replace(/^fix(\(.+?\))?:\s*/, '')}`);
71
+ } else if (/^security(\(.+?\))?:\s/.test(cleaned)) {
72
+ categories['Security Fixes'].push(`- ${cleaned.replace(/^security(\(.+?\))?:\s*/, '')}`);
73
+ } else if (/^ci(\(.+?\))?:\s/.test(cleaned)) {
74
+ categories['CI/CD'].push(`- ${cleaned.replace(/^ci(\(.+?\))?:\s*/, '')}`);
75
+ } else if (/^docs(\(.+?\))?:\s/.test(cleaned)) {
76
+ categories['Documentation'].push(`- ${cleaned.replace(/^docs(\(.+?\))?:\s*/, '')}`);
77
+ } else if (/^(chore|refactor|perf|style)(\(.+?\))?:\s/.test(cleaned)) {
78
+ categories['Improvements'].push(`- ${cleaned.replace(/^(chore|refactor|perf|style)(\(.+?\))?:\s*/, '')}`);
79
+ } else if (cleaned.trim()) {
80
+ categories['Improvements'].push(`- ${cleaned}`);
38
81
  }
39
82
  }
40
83
 
41
84
  return categories;
42
85
  }
43
86
 
87
+ /**
88
+ * Auto-detect bump type from commit messages.
89
+ *
90
+ * Rules:
91
+ * BREAKING CHANGE or feat!: → major
92
+ * feat: → minor
93
+ * fix:/ci:/docs:/chore: → patch
94
+ */
95
+ export function detectBumpType(messages) {
96
+ let hasBreaking = false;
97
+ let hasFeat = false;
98
+
99
+ for (const msg of messages) {
100
+ if (msg.includes('BREAKING CHANGE') || /^[a-z]+!:/.test(msg)) {
101
+ hasBreaking = true;
102
+ }
103
+ if (/^feat(\(.+?\))?:\s/.test(msg)) {
104
+ hasFeat = true;
105
+ }
106
+ }
107
+
108
+ if (hasBreaking) return 'major';
109
+ if (hasFeat) return 'minor';
110
+ return 'patch';
111
+ }
112
+
113
+ /**
114
+ * Build changelog entries from git commits + resolved tickets.
115
+ */
116
+ export async function buildFromTickets(ctx) {
117
+ // Start with commit-based categories
118
+ const commits = getCommitsSinceLastTag(ctx.cwd);
119
+ const categories = categorizeCommits(commits);
120
+
121
+ // Merge ticket-based entries
122
+ try {
123
+ const entries = await loadIndex(ctx);
124
+ const resolved = entries.filter(e =>
125
+ e.status === 'resolved' || e.status === 'closed'
126
+ );
127
+
128
+ for (const ticket of resolved) {
129
+ const line = `- \`${ticket.id}\`: ${ticket.title}`;
130
+ if (ticket.type === 'feature') {
131
+ categories['New Features'].push(line);
132
+ } else if (ticket.severity === 'critical' && ticket.title.toLowerCase().includes('security')) {
133
+ categories['Security Fixes'].push(line);
134
+ } else if (ticket.type === 'bug') {
135
+ categories['Bug Fixes'].push(line);
136
+ } else {
137
+ categories['Improvements'].push(line);
138
+ }
139
+ }
140
+ } catch {
141
+ // ticket index not available — commits only
142
+ }
143
+
144
+ return categories;
145
+ }
146
+
44
147
  /**
45
148
  * Generate a formatted changelog entry string.
46
149
  */
47
150
  export function generateChangelogEntry(version, date, categories) {
48
151
  const lines = [`## [${version}] — ${date}`, ''];
49
152
 
153
+ let hasContent = false;
50
154
  for (const [category, items] of Object.entries(categories)) {
51
155
  if (items.length > 0) {
156
+ hasContent = true;
52
157
  lines.push(`### ${category}`, '');
53
- for (const item of items) {
158
+ // Deduplicate
159
+ const unique = [...new Set(items)];
160
+ for (const item of unique) {
54
161
  lines.push(item);
55
162
  }
56
163
  lines.push('');
57
164
  }
58
165
  }
59
166
 
167
+ if (!hasContent) {
168
+ lines.push('- Maintenance release', '');
169
+ }
170
+
60
171
  return lines.join('\n');
61
172
  }
62
173
 
@@ -85,26 +196,10 @@ export async function prependToChangelog(ctx, entry) {
85
196
  }
86
197
 
87
198
  /**
88
- * Generate a changelog entry from governor-state + tickets for a given version.
199
+ * Generate a changelog entry from git commits + tickets for a given version.
89
200
  */
90
201
  export async function generateForVersion(ctx, version) {
91
202
  const date = new Date().toISOString().slice(0, 10);
92
203
  const categories = await buildFromTickets(ctx);
93
-
94
- // Also check governor-state for the latest request
95
- if (ctx.exists('tasks/governor-state.json')) {
96
- try {
97
- const state = await ctx.readJSON('tasks/governor-state.json');
98
- if (state.status === 'completed' && state.request) {
99
- const line = `- ${state.request} (cycle: ${state.cycle_id})`;
100
- if (!Object.values(categories).flat().length) {
101
- categories['Improvements'].push(line);
102
- }
103
- }
104
- } catch {
105
- // Non-critical
106
- }
107
- }
108
-
109
204
  return generateChangelogEntry(version, date, categories);
110
205
  }
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { spawnSync } from 'node:child_process';
17
+ import { existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
17
19
  import { runGates } from './verify-gates.js';
18
20
 
19
21
  /**
@@ -191,27 +193,59 @@ export async function runChainedPipeline(request, cycleId, state, ctx, options =
191
193
  };
192
194
  }
193
195
 
196
+ // ── 에이전트 이름 → phase 이름 매핑 ────────────────────
197
+
198
+ const AGENT_MAP = {
199
+ plan: 'planner',
200
+ dev: 'dev',
201
+ review: 'pr-review',
202
+ finalize: null, // finalize는 전용 에이전트 없음
203
+ };
204
+
194
205
  // ── Phase 실행 ────────────────────────────────────────
195
206
 
196
207
  function runPhase(name, prompt, ctx) {
197
- const result = spawnSync('claude', ['-p', prompt], {
208
+ const args = ['-p', prompt, '--output-format', 'json'];
209
+
210
+ // .claude/agents/ 에 에이전트가 있으면 --agent 플래그 추가
211
+ const agentName = AGENT_MAP[name];
212
+ if (agentName && existsSync(join(ctx.cwd, '.claude', 'agents', `${agentName}.md`))) {
213
+ args.push('--agent', agentName);
214
+ }
215
+
216
+ const result = spawnSync('claude', args, {
198
217
  cwd: ctx.cwd,
199
- stdio: 'inherit',
200
- timeout: 300_000, // 5분 per phase (전체 10분 대신)
218
+ encoding: 'utf-8',
219
+ stdio: 'pipe',
220
+ timeout: 300_000, // 5분 per phase
201
221
  });
202
222
 
203
223
  if (result.error) {
204
224
  ctx.error(`Phase ${name} failed: ${result.error.message}`);
205
- return { success: false, error: result.error.message, exit_code: null };
225
+ return { success: false, error: result.error.message, exit_code: null, output: null };
206
226
  }
207
227
 
208
228
  if (result.status !== 0) {
209
229
  ctx.error(`Phase ${name} exited with code ${result.status}`);
210
- return { success: false, error: `exit code ${result.status}`, exit_code: result.status };
230
+ // stderr가 있으면 출력
231
+ if (result.stderr?.trim()) {
232
+ ctx.log(result.stderr.slice(-500));
233
+ }
234
+ return { success: false, error: `exit code ${result.status}`, exit_code: result.status, output: null };
235
+ }
236
+
237
+ // JSON 출력 파싱
238
+ let output = null;
239
+ try {
240
+ output = JSON.parse(result.stdout);
241
+ ctx.success(`Phase ${name} completed (${output.usage?.output_tokens || '?'} tokens)`);
242
+ } catch {
243
+ // JSON 파싱 실패 — 텍스트 그대로 사용
244
+ output = { content: result.stdout };
245
+ ctx.success(`Phase ${name} completed`);
211
246
  }
212
247
 
213
- ctx.success(`Phase ${name} completed`);
214
- return { success: true, error: null, exit_code: 0 };
248
+ return { success: true, error: null, exit_code: 0, output };
215
249
  }
216
250
 
217
251
  // ── 최근 티켓 내용 가져오기 ───────────────────────────