ultra-builder-pro-cli 0.14.0 → 0.16.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/.ultra-template/docs/research/README.md +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +77 -1
- package/README.md +46 -24
- package/adapters/_shared/codex-assets.cjs +6 -2
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/handbook.cjs +2 -1
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +3 -2
- package/agents/review-coordinator.md +9 -6
- package/commands/ultra-change.md +1 -1
- package/commands/ultra-plan.md +1 -1
- package/commands/ultra-research.md +1 -1
- package/commands/ultra-think.md +2 -2
- package/docs/AGENT-CONTEXT.md +43 -19
- package/docs/ARCHITECTURE.md +54 -13
- package/docs/PLAN.zh-CN.md +4 -2
- package/docs/ROADMAP.md +10 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
- package/docs/WORKFLOW-LIFECYCLE.md +342 -0
- package/hooks/README.md +6 -1
- package/hooks/health_check.py +12 -3
- package/hooks/subagent_tracker.py +52 -77
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +598 -85
- package/mcp-server/lib/context-spine.cjs +305 -25
- package/mcp-server/lib/decision-dialogue.cjs +713 -0
- package/mcp-server/lib/doctor.cjs +42 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +15 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +168 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/workflow-state.cjs +2305 -0
- package/mcp-server/server.cjs +215 -15
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +13 -3
- package/skills/ultra-change/SKILL.md +94 -80
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +76 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +62 -59
- package/skills/ultra-doctor/SKILL.md +61 -82
- package/skills/ultra-init/SKILL.md +101 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +99 -85
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +102 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +63 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -41
- package/skills/ultra-test/SKILL.md +54 -55
- package/skills/ultra-think/SKILL.md +26 -12
- package/skills/ultra-think/references/decision-dialogue.md +130 -0
- package/skills/ultra-verify/SKILL.md +1 -1
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/spec/cli-protocol.md +17 -3
- package/spec/mcp-tools.yaml +683 -64
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +140 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +228 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -138
- package/adapters/tests/codex-hook.test.cjs +0 -142
- package/adapters/tests/codex.test.cjs +0 -430
- package/adapters/tests/kimi-hook.test.cjs +0 -149
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -357
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -82
- package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -169
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1467
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 6.2 — Telemetry collectors:
|
|
4
|
-
// • appendTelemetry writes to state.db.telemetry AND .ultra/telemetry/
|
|
5
|
-
// {YYYY-MM-DD}.jsonl (double-write; jsonl keeps full payload the table
|
|
6
|
-
// can't hold).
|
|
7
|
-
// • computeCost(runtime, model, ti, to) returns USD from 2026-04 pricing.
|
|
8
|
-
// • aggregateTelemetryByRuntime / byTask / bySession drive /ultra-status.
|
|
9
|
-
|
|
10
|
-
const { test } = require('node:test');
|
|
11
|
-
const assert = require('node:assert/strict');
|
|
12
|
-
const fs = require('node:fs');
|
|
13
|
-
const os = require('node:os');
|
|
14
|
-
const path = require('node:path');
|
|
15
|
-
|
|
16
|
-
const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
|
|
17
|
-
const ops = require('../lib/state-ops.cjs');
|
|
18
|
-
const telemetry = require('../lib/telemetry.cjs');
|
|
19
|
-
const pricing = require('../lib/pricing.cjs');
|
|
20
|
-
|
|
21
|
-
function freshFixture() {
|
|
22
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-tele-'));
|
|
23
|
-
const file = path.join(dir, '.ultra', 'state.db');
|
|
24
|
-
const init = initStateDb(file);
|
|
25
|
-
return { dir, db: init.db };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function teardown(dir, db) {
|
|
29
|
-
try { closeStateDb(db); } catch (_) { /* ignore */ }
|
|
30
|
-
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function seedSession(db, { sid = 's-1', task_id = 't-1', runtime = 'claude' } = {}) {
|
|
34
|
-
ops.createTask(db, { id: task_id, title: 'tele target', type: 'feature', priority: 'P1' });
|
|
35
|
-
ops.createSession(db, {
|
|
36
|
-
sid, task_id, runtime, pid: null,
|
|
37
|
-
worktree_path: `/tmp/${sid}/wt`,
|
|
38
|
-
artifact_dir: `/tmp/${sid}/art`,
|
|
39
|
-
});
|
|
40
|
-
return { sid, task_id, runtime };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ─── pricing ──────────────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
test('computeCost: claude opus produces non-zero USD for realistic tokens', () => {
|
|
46
|
-
const cost = pricing.computeCost('claude', 'claude-opus-4-7', 1000, 500);
|
|
47
|
-
assert.ok(cost > 0 && cost < 1, `expected reasonable cost, got ${cost}`);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('computeCost: unknown runtime → null', () => {
|
|
51
|
-
assert.equal(pricing.computeCost('madeup', 'model-x', 100, 100), null);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('computeCost: zero tokens → zero', () => {
|
|
55
|
-
assert.equal(pricing.computeCost('claude', 'claude-sonnet-4-6', 0, 0), 0);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// ─── appendTelemetry ─────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
test('appendTelemetry writes to telemetry table', () => {
|
|
61
|
-
const { dir, db } = freshFixture();
|
|
62
|
-
try {
|
|
63
|
-
seedSession(db, { sid: 's-t1' });
|
|
64
|
-
const out = telemetry.appendTelemetry(db, {
|
|
65
|
-
event_type: 'tool_call',
|
|
66
|
-
tool_name: 'task.list',
|
|
67
|
-
session_id: 's-t1',
|
|
68
|
-
rootDir: dir,
|
|
69
|
-
});
|
|
70
|
-
assert.ok(out.id > 0);
|
|
71
|
-
const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
|
|
72
|
-
assert.equal(row.event_type, 'tool_call');
|
|
73
|
-
assert.equal(row.tool_name, 'task.list');
|
|
74
|
-
assert.equal(row.session_id, 's-t1');
|
|
75
|
-
} finally { teardown(dir, db); }
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('appendTelemetry writes a jsonl line under .ultra/telemetry/YYYY-MM-DD.jsonl', () => {
|
|
79
|
-
const { dir, db } = freshFixture();
|
|
80
|
-
try {
|
|
81
|
-
seedSession(db, { sid: 's-t2' });
|
|
82
|
-
telemetry.appendTelemetry(db, {
|
|
83
|
-
event_type: 'tool_call',
|
|
84
|
-
tool_name: 'task.create',
|
|
85
|
-
session_id: 's-t2',
|
|
86
|
-
rootDir: dir,
|
|
87
|
-
payload: { duration_ms: 42 },
|
|
88
|
-
});
|
|
89
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
90
|
-
const jsonl = path.join(dir, '.ultra', 'telemetry', `${today}.jsonl`);
|
|
91
|
-
assert.ok(fs.existsSync(jsonl), `expected jsonl at ${jsonl}`);
|
|
92
|
-
const lines = fs.readFileSync(jsonl, 'utf8').trim().split('\n');
|
|
93
|
-
assert.equal(lines.length, 1);
|
|
94
|
-
const parsed = JSON.parse(lines[0]);
|
|
95
|
-
assert.equal(parsed.tool_name, 'task.create');
|
|
96
|
-
assert.equal(parsed.payload.duration_ms, 42);
|
|
97
|
-
} finally { teardown(dir, db); }
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('appendTelemetry auto-computes cost_usd when runtime + tokens supplied', () => {
|
|
101
|
-
const { dir, db } = freshFixture();
|
|
102
|
-
try {
|
|
103
|
-
seedSession(db, { sid: 's-t3' });
|
|
104
|
-
const out = telemetry.appendTelemetry(db, {
|
|
105
|
-
event_type: 'token_usage',
|
|
106
|
-
tool_name: 'session.close',
|
|
107
|
-
session_id: 's-t3',
|
|
108
|
-
runtime: 'claude',
|
|
109
|
-
tokens_input: 2000,
|
|
110
|
-
tokens_output: 500,
|
|
111
|
-
rootDir: dir,
|
|
112
|
-
payload: { model: 'claude-sonnet-4-6' },
|
|
113
|
-
});
|
|
114
|
-
const row = db.prepare('SELECT cost_usd FROM telemetry WHERE id = ?').get(out.id);
|
|
115
|
-
assert.ok(row.cost_usd > 0, 'cost should be computed from tokens');
|
|
116
|
-
} finally { teardown(dir, db); }
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test('appendTelemetry tolerates null session_id (pre-session CLI calls)', () => {
|
|
120
|
-
const { dir, db } = freshFixture();
|
|
121
|
-
try {
|
|
122
|
-
const out = telemetry.appendTelemetry(db, {
|
|
123
|
-
event_type: 'tool_call',
|
|
124
|
-
tool_name: 'cli.task.list',
|
|
125
|
-
session_id: null,
|
|
126
|
-
rootDir: dir,
|
|
127
|
-
});
|
|
128
|
-
const row = db.prepare('SELECT * FROM telemetry WHERE id = ?').get(out.id);
|
|
129
|
-
assert.equal(row.session_id, null);
|
|
130
|
-
} finally { teardown(dir, db); }
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test('appendTelemetry event_type constraint: invalid value rejected', () => {
|
|
134
|
-
const { dir, db } = freshFixture();
|
|
135
|
-
try {
|
|
136
|
-
assert.throws(
|
|
137
|
-
() => telemetry.appendTelemetry(db, { event_type: 'bogus', rootDir: dir }),
|
|
138
|
-
/CHECK|constraint|event_type/,
|
|
139
|
-
);
|
|
140
|
-
} finally { teardown(dir, db); }
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('appendTelemetry rejects unsupported runtime labels', () => {
|
|
144
|
-
const { dir, db } = freshFixture();
|
|
145
|
-
const retiredRuntime = ['gem', 'ini'].join('');
|
|
146
|
-
try {
|
|
147
|
-
assert.throws(
|
|
148
|
-
() => telemetry.appendTelemetry(db, {
|
|
149
|
-
event_type: 'tool_call', runtime: retiredRuntime, rootDir: dir,
|
|
150
|
-
}),
|
|
151
|
-
/unsupported runtime/,
|
|
152
|
-
);
|
|
153
|
-
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM telemetry').get().n, 0);
|
|
154
|
-
} finally { teardown(dir, db); }
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// ─── aggregation ─────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
test('aggregateTelemetryByRuntime: groups calls + sums tokens/cost', () => {
|
|
160
|
-
const { dir, db } = freshFixture();
|
|
161
|
-
try {
|
|
162
|
-
seedSession(db, { sid: 's-a1', task_id: 't-a1', runtime: 'claude' });
|
|
163
|
-
seedSession(db, { sid: 's-a2', task_id: 't-a2', runtime: 'codex' });
|
|
164
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-a1', runtime: 'claude', tokens_input: 1000, tokens_output: 200, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
|
|
165
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-a1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
|
|
166
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'z', session_id: 's-a2', runtime: 'codex', tokens_input: 800, tokens_output: 300, rootDir: dir, payload: { model: 'gpt-5.4' } });
|
|
167
|
-
|
|
168
|
-
const summary = ops.aggregateTelemetryByRuntime(db);
|
|
169
|
-
const byRuntime = Object.fromEntries(summary.map((r) => [r.runtime, r]));
|
|
170
|
-
assert.equal(byRuntime.claude.calls, 2);
|
|
171
|
-
assert.equal(byRuntime.codex.calls, 1);
|
|
172
|
-
assert.equal(byRuntime.claude.tokens_in, 1500);
|
|
173
|
-
assert.equal(byRuntime.codex.tokens_in, 800);
|
|
174
|
-
assert.ok(byRuntime.claude.cost_usd > 0);
|
|
175
|
-
} finally { teardown(dir, db); }
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test('aggregateTelemetryByTask: top N tasks by cost', () => {
|
|
179
|
-
const { dir, db } = freshFixture();
|
|
180
|
-
try {
|
|
181
|
-
seedSession(db, { sid: 's-b1', task_id: 't-cheap', runtime: 'claude' });
|
|
182
|
-
seedSession(db, { sid: 's-b2', task_id: 't-expensive', runtime: 'claude' });
|
|
183
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'x', session_id: 's-b1', runtime: 'claude', tokens_input: 100, tokens_output: 50, rootDir: dir, payload: { model: 'claude-sonnet-4-6' } });
|
|
184
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'y', session_id: 's-b2', runtime: 'claude', tokens_input: 10000, tokens_output: 5000, rootDir: dir, payload: { model: 'claude-opus-4-7' } });
|
|
185
|
-
|
|
186
|
-
const top = ops.aggregateTelemetryByTask(db, { limit: 1 });
|
|
187
|
-
assert.equal(top.length, 1);
|
|
188
|
-
assert.equal(top[0].task_id, 't-expensive');
|
|
189
|
-
assert.ok(top[0].cost_usd > 0);
|
|
190
|
-
} finally { teardown(dir, db); }
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('aggregateTelemetryBySession: per-session roll-up', () => {
|
|
194
|
-
const { dir, db } = freshFixture();
|
|
195
|
-
try {
|
|
196
|
-
seedSession(db, { sid: 's-c1', task_id: 't-c1', runtime: 'claude' });
|
|
197
|
-
telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.list', session_id: 's-c1', rootDir: dir });
|
|
198
|
-
telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'task.get', session_id: 's-c1', rootDir: dir });
|
|
199
|
-
telemetry.appendTelemetry(db, { event_type: 'token_usage', tool_name: 'session.close', session_id: 's-c1', runtime: 'claude', tokens_input: 500, tokens_output: 100, rootDir: dir, payload: { model: 'claude-haiku-4-5' } });
|
|
200
|
-
|
|
201
|
-
const summary = ops.aggregateTelemetryBySession(db, 's-c1');
|
|
202
|
-
assert.equal(summary.tool_calls, 3);
|
|
203
|
-
assert.equal(summary.tokens_in, 500);
|
|
204
|
-
assert.equal(summary.tokens_out, 100);
|
|
205
|
-
assert.ok(summary.cost_usd > 0);
|
|
206
|
-
} finally { teardown(dir, db); }
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('aggregate filter: since cutoff excludes older rows', () => {
|
|
210
|
-
const { dir, db } = freshFixture();
|
|
211
|
-
try {
|
|
212
|
-
seedSession(db, { sid: 's-d1', task_id: 't-d1', runtime: 'claude' });
|
|
213
|
-
// Insert one row; then a cutoff in the future filters it out.
|
|
214
|
-
telemetry.appendTelemetry(db, { event_type: 'tool_call', tool_name: 'x', session_id: 's-d1', rootDir: dir });
|
|
215
|
-
const futureCutoff = new Date(Date.now() + 60 * 1000).toISOString();
|
|
216
|
-
const summary = ops.aggregateTelemetryByRuntime(db, { since: futureCutoff });
|
|
217
|
-
assert.equal(summary.length, 0);
|
|
218
|
-
} finally { teardown(dir, db); }
|
|
219
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const test = require('node:test');
|
|
4
|
-
const assert = require('node:assert/strict');
|
|
5
|
-
|
|
6
|
-
const { buildPlan, listWaveConflicts } = require('./plan-builder.cjs');
|
|
7
|
-
|
|
8
|
-
function mk(id, deps = [], files = [], complexity = 3) {
|
|
9
|
-
return { id, deps, files_modified: files, complexity };
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
test('buildPlan fixture 1: 5 tasks, no file overlap → 2 waves, no conflicts', () => {
|
|
13
|
-
const tasks = [
|
|
14
|
-
mk('a', [], ['src/a.ts'], 2),
|
|
15
|
-
mk('b', ['a'], ['src/b.ts'], 3),
|
|
16
|
-
mk('c', ['a'], ['src/c.ts'], 4),
|
|
17
|
-
mk('d', ['b', 'c'], ['src/d.ts'], 5),
|
|
18
|
-
mk('e', [], ['src/e.ts'], 2),
|
|
19
|
-
];
|
|
20
|
-
const plan = buildPlan(tasks);
|
|
21
|
-
assert.equal(plan.waves.length, 3);
|
|
22
|
-
assert.equal(plan.conflict_surface.length, 0);
|
|
23
|
-
// wave 1 should contain a+e, both parallel-safe
|
|
24
|
-
assert.deepEqual(new Set(plan.waves[0].tasks), new Set(['a', 'e']));
|
|
25
|
-
assert.equal(plan.waves[0].parallel, true);
|
|
26
|
-
assert.deepEqual(plan.cycles, []);
|
|
27
|
-
assert.ok(plan.estimated_cost_usd > 0);
|
|
28
|
-
assert.ok(plan.estimated_duration_min > 0);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('buildPlan fixture 2: same wave, shared file → conflict_surface + parallel=false', () => {
|
|
32
|
-
const tasks = [
|
|
33
|
-
mk('a', []),
|
|
34
|
-
mk('b', ['a'], ['src/utils.ts']),
|
|
35
|
-
mk('c', ['a'], ['src/utils.ts']),
|
|
36
|
-
];
|
|
37
|
-
const plan = buildPlan(tasks);
|
|
38
|
-
assert.equal(plan.waves.length, 2);
|
|
39
|
-
const w2 = plan.waves[1];
|
|
40
|
-
assert.equal(w2.parallel, false);
|
|
41
|
-
assert.match(w2.reason, /shared files: src\/utils\.ts/);
|
|
42
|
-
|
|
43
|
-
assert.equal(plan.conflict_surface.length, 1);
|
|
44
|
-
const c = plan.conflict_surface[0];
|
|
45
|
-
assert.deepEqual(c.files, ['src/utils.ts']);
|
|
46
|
-
assert.deepEqual(new Set(c.tasks), new Set(['b', 'c']));
|
|
47
|
-
assert.equal(c.recommend, 'sequentialize');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('buildPlan fixture 3: cross-wave same file → NOT a conflict', () => {
|
|
51
|
-
const tasks = [
|
|
52
|
-
mk('a', [], ['src/shared.ts']),
|
|
53
|
-
mk('b', ['a'], ['src/shared.ts']),
|
|
54
|
-
];
|
|
55
|
-
const plan = buildPlan(tasks);
|
|
56
|
-
assert.equal(plan.conflict_surface.length, 0);
|
|
57
|
-
// wave 1 has only `a`, not parallel; wave 2 has only `b`, not parallel
|
|
58
|
-
for (const w of plan.waves) {
|
|
59
|
-
assert.equal(w.parallel, false);
|
|
60
|
-
assert.ok(!w.reason);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('buildPlan fixture 4: empty tasks → empty plan, non-negative numbers', () => {
|
|
65
|
-
const plan = buildPlan([]);
|
|
66
|
-
assert.deepEqual(plan.waves, []);
|
|
67
|
-
assert.deepEqual(plan.conflict_surface, []);
|
|
68
|
-
assert.deepEqual(plan.ownership_forecast, {});
|
|
69
|
-
assert.equal(plan.estimated_cost_usd, 0);
|
|
70
|
-
assert.equal(plan.estimated_duration_min, 0);
|
|
71
|
-
assert.deepEqual(plan.cycles, []);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('buildPlan fixture 5: cost + duration math (parallel vs serial waves)', () => {
|
|
75
|
-
// wave 1 (parallel): a complexity=2, b complexity=4 → max = 4 * 5 = 20 min
|
|
76
|
-
// wave 2 (1 task, not parallel): c complexity=6 → 6 * 5 = 30 min
|
|
77
|
-
// total duration = 50 min
|
|
78
|
-
const tasks = [
|
|
79
|
-
mk('a', [], ['src/a.ts'], 2),
|
|
80
|
-
mk('b', [], ['src/b.ts'], 4),
|
|
81
|
-
mk('c', ['a', 'b'], ['src/c.ts'], 6),
|
|
82
|
-
];
|
|
83
|
-
const plan = buildPlan(tasks);
|
|
84
|
-
assert.equal(plan.estimated_duration_min, 50);
|
|
85
|
-
// cost: 3 tasks × sum(complexity)=12 × (5000*3e-6 + 2000*15e-6) per complexity unit
|
|
86
|
-
// = 12 × (0.015 + 0.030) = 12 × 0.045 = 0.54 usd
|
|
87
|
-
assert.ok(Math.abs(plan.estimated_cost_usd - 0.54) < 0.001);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('buildPlan: serial wave (with conflict) sums complexities instead of max', () => {
|
|
91
|
-
// both tasks in same wave share a file → wave is serial
|
|
92
|
-
// wave 1: a(complexity=3), b(complexity=3) → (3+3) * 5 = 30 min
|
|
93
|
-
const tasks = [
|
|
94
|
-
mk('a', [], ['src/utils.ts'], 3),
|
|
95
|
-
mk('b', [], ['src/utils.ts'], 3),
|
|
96
|
-
];
|
|
97
|
-
const plan = buildPlan(tasks);
|
|
98
|
-
assert.equal(plan.waves[0].parallel, false);
|
|
99
|
-
assert.equal(plan.estimated_duration_min, 30);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('buildPlan: ownership_forecast captures per-task files_modified verbatim', () => {
|
|
103
|
-
const tasks = [
|
|
104
|
-
mk('a', [], ['src/a.ts', 'src/a.util.ts']),
|
|
105
|
-
mk('b', [], ['src/b.ts']),
|
|
106
|
-
];
|
|
107
|
-
const plan = buildPlan(tasks);
|
|
108
|
-
assert.deepEqual(plan.ownership_forecast, {
|
|
109
|
-
a: ['src/a.ts', 'src/a.util.ts'],
|
|
110
|
-
b: ['src/b.ts'],
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('buildPlan: cycle in task graph surfaced in plan.cycles', () => {
|
|
115
|
-
const tasks = [
|
|
116
|
-
mk('x', ['y']),
|
|
117
|
-
mk('y', ['x']),
|
|
118
|
-
];
|
|
119
|
-
const plan = buildPlan(tasks);
|
|
120
|
-
assert.deepEqual(plan.waves, []);
|
|
121
|
-
assert.equal(plan.cycles.length, 1);
|
|
122
|
-
assert.deepEqual(new Set(plan.cycles[0]), new Set(['x', 'y']));
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('buildPlan: missing complexity defaults to DEFAULT_COMPLEXITY for cost calc', () => {
|
|
126
|
-
const tasks = [{ id: 'a', deps: [], files_modified: [] }];
|
|
127
|
-
const plan = buildPlan(tasks);
|
|
128
|
-
// complexity 3 → 15k in, 6k out → cost > 0
|
|
129
|
-
assert.ok(plan.estimated_cost_usd > 0);
|
|
130
|
-
assert.equal(plan.estimated_duration_min, 15); // 3 * 5 (single-task wave, not parallel)
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test('buildPlan: non-array input → TypeError', () => {
|
|
134
|
-
assert.throws(() => buildPlan(null), TypeError);
|
|
135
|
-
assert.throws(() => buildPlan({}), TypeError);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test('listWaveConflicts: no overlap → empty', () => {
|
|
139
|
-
const byId = new Map([
|
|
140
|
-
['a', { id: 'a', files_modified: ['x.ts'] }],
|
|
141
|
-
['b', { id: 'b', files_modified: ['y.ts'] }],
|
|
142
|
-
]);
|
|
143
|
-
assert.deepEqual(listWaveConflicts(['a', 'b'], byId), []);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('listWaveConflicts: pair overlap reported once', () => {
|
|
147
|
-
const byId = new Map([
|
|
148
|
-
['a', { id: 'a', files_modified: ['shared.ts', 'x.ts'] }],
|
|
149
|
-
['b', { id: 'b', files_modified: ['shared.ts'] }],
|
|
150
|
-
]);
|
|
151
|
-
const out = listWaveConflicts(['a', 'b'], byId);
|
|
152
|
-
assert.equal(out.length, 1);
|
|
153
|
-
assert.deepEqual(out[0].files, ['shared.ts']);
|
|
154
|
-
assert.deepEqual(out[0].tasks, ['a', 'b']);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test('listWaveConflicts: three-way overlap yields C(3,2)=3 pairs', () => {
|
|
158
|
-
const byId = new Map([
|
|
159
|
-
['a', { id: 'a', files_modified: ['x.ts'] }],
|
|
160
|
-
['b', { id: 'b', files_modified: ['x.ts'] }],
|
|
161
|
-
['c', { id: 'c', files_modified: ['x.ts'] }],
|
|
162
|
-
]);
|
|
163
|
-
const out = listWaveConflicts(['a', 'b', 'c'], byId);
|
|
164
|
-
assert.equal(out.length, 3);
|
|
165
|
-
});
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8B.4 — Auto-merge back.
|
|
4
|
-
//
|
|
5
|
-
// autoMerge({ repoRoot, worktreePath, baseBranch, sid, task_id, db }):
|
|
6
|
-
// • no changes (session_sha === base_sha) → { merged:false, reason:'no_changes' }
|
|
7
|
-
// • clean merge → { merged:true } + merged_back event
|
|
8
|
-
// • conflict → { merged:false, reason:'conflict', conflict_paths[] }
|
|
9
|
-
// + merge_conflict event + merge --abort
|
|
10
|
-
// closeSession({ autoMerge:true }) integration:
|
|
11
|
-
// • autoMerge=true + conflict → worktree kept on disk (for human to resolve)
|
|
12
|
-
// • autoMerge=false (default) → behavior unchanged (Phase 4.5 / 5 / 7 tests)
|
|
13
|
-
|
|
14
|
-
const { test } = require('node:test');
|
|
15
|
-
const assert = require('node:assert/strict');
|
|
16
|
-
const fs = require('node:fs');
|
|
17
|
-
const os = require('node:os');
|
|
18
|
-
const path = require('node:path');
|
|
19
|
-
const { execFileSync } = require('node:child_process');
|
|
20
|
-
|
|
21
|
-
const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
22
|
-
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
23
|
-
const wtmgr = require('../worktree-manager.cjs');
|
|
24
|
-
const autoMerge = require('../auto-merge.cjs');
|
|
25
|
-
const runner = require('../session-runner.cjs');
|
|
26
|
-
|
|
27
|
-
function mkRepo() {
|
|
28
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-automerge-'));
|
|
29
|
-
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
|
|
30
|
-
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
|
|
31
|
-
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
|
|
32
|
-
fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
|
|
33
|
-
// The DB and worktree dirs live under .ultra/ — keep them out of git so
|
|
34
|
-
// `git status` in conflict tests isn't polluted by untracked test artifacts.
|
|
35
|
-
fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/\n');
|
|
36
|
-
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
37
|
-
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
|
|
38
|
-
return dir;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function mkDb(repoRoot) {
|
|
42
|
-
const dbPath = path.join(repoRoot, '.ultra', 'state.db');
|
|
43
|
-
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
44
|
-
const { db } = initStateDb(dbPath);
|
|
45
|
-
return db;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function commitInWorktree(wtPath, filename, content, msg = 'session change') {
|
|
49
|
-
fs.writeFileSync(path.join(wtPath, filename), content);
|
|
50
|
-
execFileSync('git', ['add', '-A'], { cwd: wtPath });
|
|
51
|
-
execFileSync('git', ['-c', 'user.email=s@ubp.dev', '-c', 'user.name=s-ubp',
|
|
52
|
-
'commit', '-q', '-m', msg], { cwd: wtPath });
|
|
53
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: wtPath, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
54
|
-
.toString().trim();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function cleanup(repoRoot, db) {
|
|
58
|
-
try { if (db) closeStateDb(db); } catch (_) { /* best-effort */ }
|
|
59
|
-
try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── autoMerge pure function ──────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
test('autoMerge: no session commits → merged:false reason:no_changes', () => {
|
|
65
|
-
const repo = mkRepo();
|
|
66
|
-
try {
|
|
67
|
-
const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid: 's-noop' });
|
|
68
|
-
const r = autoMerge.autoMerge({
|
|
69
|
-
repoRoot: repo, worktreePath: worktree_path,
|
|
70
|
-
baseBranch: 'main', sid: 's-noop',
|
|
71
|
-
});
|
|
72
|
-
assert.equal(r.merged, false);
|
|
73
|
-
assert.equal(r.reason, 'no_changes');
|
|
74
|
-
} finally { cleanup(repo); }
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('autoMerge: 3 slices with independent files → all merged back to main', () => {
|
|
78
|
-
const repo = mkRepo();
|
|
79
|
-
const db = mkDb(repo);
|
|
80
|
-
try {
|
|
81
|
-
for (const sid of ['s1', 's2', 's3']) {
|
|
82
|
-
ops.createTask(db, { id: sid, title: sid, type: 'feature', priority: 'P2' });
|
|
83
|
-
}
|
|
84
|
-
const shas = {};
|
|
85
|
-
for (const [sid, file] of [['s1', 'a.txt'], ['s2', 'b.txt'], ['s3', 'c.txt']]) {
|
|
86
|
-
const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid });
|
|
87
|
-
shas[sid] = commitInWorktree(worktree_path, file, `${file}\n`);
|
|
88
|
-
const r = autoMerge.autoMerge({
|
|
89
|
-
repoRoot: repo, worktreePath: worktree_path,
|
|
90
|
-
baseBranch: 'main', sid, task_id: sid, db,
|
|
91
|
-
});
|
|
92
|
-
assert.equal(r.merged, true, `${sid} should merge`);
|
|
93
|
-
assert.equal(r.session_sha, shas[sid]);
|
|
94
|
-
}
|
|
95
|
-
assert.ok(fs.existsSync(path.join(repo, 'a.txt')));
|
|
96
|
-
assert.ok(fs.existsSync(path.join(repo, 'b.txt')));
|
|
97
|
-
assert.ok(fs.existsSync(path.join(repo, 'c.txt')));
|
|
98
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
99
|
-
const merged = events.filter((e) => e.type === 'merged_back');
|
|
100
|
-
assert.equal(merged.length, 3);
|
|
101
|
-
} finally { cleanup(repo, db); }
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('autoMerge: 2 slices on same file → first merges, second conflict + event', () => {
|
|
105
|
-
const repo = mkRepo();
|
|
106
|
-
const db = mkDb(repo);
|
|
107
|
-
try {
|
|
108
|
-
ops.createTask(db, { id: 'cf1', title: 'cf1', type: 'feature', priority: 'P2' });
|
|
109
|
-
ops.createTask(db, { id: 'cf2', title: 'cf2', type: 'feature', priority: 'P2' });
|
|
110
|
-
const { worktree_path: w1 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf1' });
|
|
111
|
-
const { worktree_path: w2 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf2' });
|
|
112
|
-
commitInWorktree(w1, 'conflict.txt', 'version-A\n');
|
|
113
|
-
commitInWorktree(w2, 'conflict.txt', 'version-B\n');
|
|
114
|
-
|
|
115
|
-
const r1 = autoMerge.autoMerge({
|
|
116
|
-
repoRoot: repo, worktreePath: w1, baseBranch: 'main', sid: 'cf1', task_id: 'cf1', db,
|
|
117
|
-
});
|
|
118
|
-
assert.equal(r1.merged, true);
|
|
119
|
-
const r2 = autoMerge.autoMerge({
|
|
120
|
-
repoRoot: repo, worktreePath: w2, baseBranch: 'main', sid: 'cf2', task_id: 'cf2', db,
|
|
121
|
-
});
|
|
122
|
-
assert.equal(r2.merged, false);
|
|
123
|
-
assert.equal(r2.reason, 'conflict');
|
|
124
|
-
assert.ok(Array.isArray(r2.conflict_paths));
|
|
125
|
-
assert.ok(r2.conflict_paths.includes('conflict.txt'));
|
|
126
|
-
|
|
127
|
-
// main HEAD rolled back to r1's merge commit, not mid-conflict
|
|
128
|
-
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
129
|
-
cwd: repo, stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
-
}).toString();
|
|
131
|
-
assert.equal(status.trim(), '', 'working tree must be clean after abort');
|
|
132
|
-
|
|
133
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
134
|
-
const conflict = events.find((e) => e.type === 'merge_conflict');
|
|
135
|
-
assert.ok(conflict, 'merge_conflict event expected');
|
|
136
|
-
assert.deepEqual(conflict.payload.conflict_paths, ['conflict.txt']);
|
|
137
|
-
} finally { cleanup(repo, db); }
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ─── closeSession integration ─────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
test('closeSession autoMerge=true + clean merge → worktree removed', () => {
|
|
143
|
-
const repo = mkRepo();
|
|
144
|
-
const db = mkDb(repo);
|
|
145
|
-
try {
|
|
146
|
-
ops.createTask(db, { id: 'cs-clean', title: 'x', type: 'feature', priority: 'P2' });
|
|
147
|
-
const handle = runner.spawnSession({
|
|
148
|
-
db, repoRoot: repo,
|
|
149
|
-
task_id: 'cs-clean', runtime: 'claude',
|
|
150
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
151
|
-
});
|
|
152
|
-
// Simulate agent commit inside worktree
|
|
153
|
-
commitInWorktree(handle.worktree_path, 'new.txt', 'hi\n');
|
|
154
|
-
// Wait for child to exit so closeSession can kill cleanly (it already did)
|
|
155
|
-
if (handle.process) { try { handle.process.kill('SIGTERM'); } catch (_) { /* noop */ } }
|
|
156
|
-
|
|
157
|
-
const result = runner.closeSession(
|
|
158
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
159
|
-
{ autoMerge: true, mergeBaseBranch: 'main' },
|
|
160
|
-
);
|
|
161
|
-
assert.equal(result.merge && result.merge.merged, true);
|
|
162
|
-
assert.equal(fs.existsSync(handle.worktree_path), false, 'clean merge → worktree removed');
|
|
163
|
-
} finally { cleanup(repo, db); }
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('closeSession autoMerge=true + conflict → worktree kept', () => {
|
|
167
|
-
const repo = mkRepo();
|
|
168
|
-
const db = mkDb(repo);
|
|
169
|
-
try {
|
|
170
|
-
ops.createTask(db, { id: 'cs-cflict', title: 'x', type: 'feature', priority: 'P2' });
|
|
171
|
-
|
|
172
|
-
// Spawn FIRST so the worktree forks at seed commit (before main diverges).
|
|
173
|
-
const handle = runner.spawnSession({
|
|
174
|
-
db, repoRoot: repo,
|
|
175
|
-
task_id: 'cs-cflict', runtime: 'claude',
|
|
176
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
177
|
-
});
|
|
178
|
-
// Advance main with a conflicting version of shared.txt.
|
|
179
|
-
fs.writeFileSync(path.join(repo, 'shared.txt'), 'main-version\n');
|
|
180
|
-
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
181
|
-
execFileSync('git', ['commit', '-q', '-m', 'main change'], { cwd: repo });
|
|
182
|
-
// Session worktree forks off seed and writes its own version of the same file.
|
|
183
|
-
commitInWorktree(handle.worktree_path, 'shared.txt', 'session-version\n');
|
|
184
|
-
|
|
185
|
-
const result = runner.closeSession(
|
|
186
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
187
|
-
{ autoMerge: true, mergeBaseBranch: 'main' },
|
|
188
|
-
);
|
|
189
|
-
assert.equal(result.merge && result.merge.merged, false);
|
|
190
|
-
assert.equal(result.merge.reason, 'conflict');
|
|
191
|
-
assert.ok(fs.existsSync(handle.worktree_path), 'conflict → worktree preserved for resolution');
|
|
192
|
-
} finally { cleanup(repo, db); }
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('closeSession autoMerge=false (default) → no merge, legacy behavior', () => {
|
|
196
|
-
const repo = mkRepo();
|
|
197
|
-
const db = mkDb(repo);
|
|
198
|
-
try {
|
|
199
|
-
ops.createTask(db, { id: 'cs-off', title: 'x', type: 'feature', priority: 'P2' });
|
|
200
|
-
const handle = runner.spawnSession({
|
|
201
|
-
db, repoRoot: repo,
|
|
202
|
-
task_id: 'cs-off', runtime: 'claude',
|
|
203
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
204
|
-
});
|
|
205
|
-
commitInWorktree(handle.worktree_path, 'n.txt', 'x\n');
|
|
206
|
-
|
|
207
|
-
const result = runner.closeSession(
|
|
208
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
209
|
-
{}, // autoMerge not set
|
|
210
|
-
);
|
|
211
|
-
assert.equal(result.merge, undefined, 'no merge when opt-in disabled');
|
|
212
|
-
assert.equal(fs.existsSync(handle.worktree_path), false, 'worktree removed normally');
|
|
213
|
-
// main must still be at seed (no merge happened)
|
|
214
|
-
assert.equal(fs.existsSync(path.join(repo, 'n.txt')), false);
|
|
215
|
-
} finally { cleanup(repo, db); }
|
|
216
|
-
});
|