tenbo-dashboard 0.7.0 → 0.9.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/README.md +14 -0
- package/bin/tenbo-dashboard.mjs +24 -8
- package/package.json +6 -5
- package/scripts/bin-output.test.ts +45 -0
- package/scripts/cliResult.ts +55 -0
- package/scripts/context.test.ts +77 -0
- package/scripts/context.ts +38 -0
- package/scripts/item.test.ts +79 -0
- package/scripts/item.ts +76 -0
- package/scripts/items.test.ts +46 -0
- package/scripts/items.ts +35 -0
- package/scripts/next-id.test.ts +2 -2
- package/scripts/next.test.ts +44 -0
- package/scripts/next.ts +16 -0
- package/src/api/lib/contextResolver.test.ts +161 -0
- package/src/api/lib/contextResolver.ts +415 -0
- package/src/api/lib/health/collectAll.test.ts +61 -0
- package/src/api/lib/health/collectAll.ts +48 -2
- package/src/api/lib/health/deadCode.test.ts +5 -1
- package/src/api/lib/health/deadCode.ts +28 -39
- package/src/api/lib/health/importGraph.test.ts +44 -0
- package/src/api/lib/health/importGraph.ts +23 -9
- package/src/api/lib/health/layerFiles.ts +10 -13
- package/src/api/lib/health/types.test.ts +9 -2
- package/src/api/lib/health/types.ts +2 -1
- package/src/api/lib/metrics.ts +4 -1
- package/src/api/lib/metricsRefresh.ts +2 -1
- package/src/api/lib/metricsRefreshQueue.ts +183 -0
- package/src/api/lib/priority.test.ts +1 -3
- package/src/api/lib/roadmapStore.test.ts +134 -0
- package/src/api/lib/roadmapStore.ts +251 -0
- package/src/api/lib/sourceIgnore.ts +23 -0
- package/src/api/lib/validator.ts +40 -0
- package/src/api/lib/validator.verification.test.ts +52 -0
- package/src/api/plugin.ts +2 -0
- package/src/api/routes/items.ts +1 -1
- package/src/api/routes/refreshMetrics.ts +40 -0
- package/src/api/routes/state.test.ts +148 -0
- package/src/api/routes/state.ts +3 -10
- package/src/hooks/usePrompt.ts +1 -1
- package/src/types.ts +24 -0
- package/src/ui/EmptyState.tsx +1 -1
- package/src/ui/HealthPage/HealthPage.module.css +18 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +22 -0
- package/src/ui/HealthPage/HealthPage.tsx +24 -1
- package/src/ui/ItemModal/PhasesSection.tsx +1 -1
- package/src/ui/WorkspacePage/tabs/DecisionsTab.tsx +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, utimesSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { resolveFeatureContext } from './contextResolver';
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(path.join(tmpdir(), 'tenbo-context-resolver-'));
|
|
11
|
+
mkdirSync(path.join(dir, '.git'));
|
|
12
|
+
mkdirSync(path.join(dir, '.tenbo/scopes/dashboard/layers/cli-tools'), { recursive: true });
|
|
13
|
+
mkdirSync(path.join(dir, '.tenbo/scopes/dashboard/layers/data-layer'), { recursive: true });
|
|
14
|
+
mkdirSync(path.join(dir, '.tenbo/scopes/skill/layers/core-logic'), { recursive: true });
|
|
15
|
+
mkdirSync(path.join(dir, '.tenbo/scopes/skill/layers/templates'), { recursive: true });
|
|
16
|
+
|
|
17
|
+
writeFileSync(path.join(dir, '.tenbo/workspace.yaml'), [
|
|
18
|
+
'scopes:',
|
|
19
|
+
' - id: dashboard',
|
|
20
|
+
' path: tenbo-dashboard',
|
|
21
|
+
' description: dashboard and CLI package',
|
|
22
|
+
' - id: skill',
|
|
23
|
+
' path: skill',
|
|
24
|
+
' description: installed agent skill behavior',
|
|
25
|
+
'cross_cutting: []',
|
|
26
|
+
'',
|
|
27
|
+
].join('\n'));
|
|
28
|
+
|
|
29
|
+
writeFileSync(path.join(dir, '.tenbo/overview.md'), [
|
|
30
|
+
'# Overview',
|
|
31
|
+
'',
|
|
32
|
+
'## Product goals',
|
|
33
|
+
'',
|
|
34
|
+
'- **g1**: Give agents holistic, persistent context about the project across sessions.',
|
|
35
|
+
'- **g2**: Maintain modularity and scalability as the codebase grows.',
|
|
36
|
+
'- **g3**: Spot refactor opportunities proactively.',
|
|
37
|
+
'',
|
|
38
|
+
'## Non-goals',
|
|
39
|
+
'',
|
|
40
|
+
'- Not a human-only project tracker.',
|
|
41
|
+
'',
|
|
42
|
+
].join('\n'));
|
|
43
|
+
|
|
44
|
+
writeFileSync(path.join(dir, '.tenbo/agent-context.md'), '# Agent Context\nOld context\n');
|
|
45
|
+
utimesSync(path.join(dir, '.tenbo/agent-context.md'), new Date('2026-05-01T00:00:00.000Z'), new Date('2026-05-01T00:00:00.000Z'));
|
|
46
|
+
|
|
47
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/architecture.yaml'), [
|
|
48
|
+
'layers:',
|
|
49
|
+
' - id: data-layer',
|
|
50
|
+
' name: Data Layer',
|
|
51
|
+
' description: Reads workspace data and prepares agent context bundles.',
|
|
52
|
+
' files: ["src/api/**", "src/types.ts"]',
|
|
53
|
+
' - id: cli-tools',
|
|
54
|
+
' name: CLI Tools',
|
|
55
|
+
' description: Command-line helpers for validation, item queries, and context fetching.',
|
|
56
|
+
' files: ["bin/**", "scripts/**"]',
|
|
57
|
+
'',
|
|
58
|
+
].join('\n'));
|
|
59
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/layers/cli-tools/intent.md'), [
|
|
60
|
+
'# CLI Tools',
|
|
61
|
+
'',
|
|
62
|
+
'Optimizing-for: typed commands that let agents fetch context without hand-reading YAML.',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n'));
|
|
65
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/layers/data-layer/intent.md'), [
|
|
66
|
+
'# Data Layer',
|
|
67
|
+
'',
|
|
68
|
+
'Optimizing-for: deterministic readers over tenbo state and roadmap data.',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n'));
|
|
71
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/dashboard/roadmap.yaml'), [
|
|
72
|
+
'items:',
|
|
73
|
+
' - id: td-010',
|
|
74
|
+
' title: Add context resolver CLI',
|
|
75
|
+
' layer: cli-tools',
|
|
76
|
+
' status: next',
|
|
77
|
+
' goal_ref: [g1]',
|
|
78
|
+
' description: Let agents fetch feature-planning context in one command.',
|
|
79
|
+
' notes: long implementation history that should not be included in context bundles',
|
|
80
|
+
' risks:',
|
|
81
|
+
' - long risk history that should stay out of matching item summaries',
|
|
82
|
+
' files_to_read:',
|
|
83
|
+
' - tenbo-dashboard/scripts/context.ts',
|
|
84
|
+
' - tenbo-dashboard/src/api/lib/contextResolver.ts',
|
|
85
|
+
'',
|
|
86
|
+
].join('\n'));
|
|
87
|
+
|
|
88
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/skill/architecture.yaml'), [
|
|
89
|
+
'layers:',
|
|
90
|
+
' - id: core-logic',
|
|
91
|
+
' name: Core Logic',
|
|
92
|
+
' description: The skill decides how to react when users ask agents to build features.',
|
|
93
|
+
' files: ["SKILL.md", "VERSION"]',
|
|
94
|
+
' - id: templates',
|
|
95
|
+
' name: Templates',
|
|
96
|
+
' description: Starter files with automatic context fetching feature request examples.',
|
|
97
|
+
' files: ["templates/**"]',
|
|
98
|
+
'',
|
|
99
|
+
].join('\n'));
|
|
100
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/skill/layers/core-logic/intent.md'), [
|
|
101
|
+
'# Core Logic',
|
|
102
|
+
'',
|
|
103
|
+
'Optimizing-for: passive context fetching before planning normal feature requests.',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n'));
|
|
106
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/skill/layers/templates/intent.md'), [
|
|
107
|
+
'# Templates',
|
|
108
|
+
'',
|
|
109
|
+
'Examples for automatic context fetching feature requests.',
|
|
110
|
+
'',
|
|
111
|
+
].join('\n'));
|
|
112
|
+
writeFileSync(path.join(dir, '.tenbo/scopes/skill/roadmap.yaml'), [
|
|
113
|
+
'items:',
|
|
114
|
+
' - id: sk-030',
|
|
115
|
+
' title: Load context automatically for feature work',
|
|
116
|
+
' layer: core-logic',
|
|
117
|
+
' status: now',
|
|
118
|
+
' goal_ref: [g1]',
|
|
119
|
+
' description: Update the skill so agents fetch project context for build requests.',
|
|
120
|
+
'',
|
|
121
|
+
].join('\n'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
125
|
+
|
|
126
|
+
describe('contextResolver', () => {
|
|
127
|
+
it('resolves feature requests into an agent-ready planning bundle', () => {
|
|
128
|
+
const bundle = resolveFeatureContext(dir, 'help me build automatic context fetching for feature requests', {
|
|
129
|
+
now: new Date('2026-06-14T12:00:00.000Z'),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(bundle).toMatchObject({
|
|
133
|
+
ok: true,
|
|
134
|
+
intent: 'feature',
|
|
135
|
+
query: 'help me build automatic context fetching for feature requests',
|
|
136
|
+
recommendation: {
|
|
137
|
+
confidence: 'high',
|
|
138
|
+
scope: 'skill',
|
|
139
|
+
layers: ['core-logic'],
|
|
140
|
+
goal_refs: ['g1'],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
expect(bundle.roadmap.active_items.map((entry) => entry.item.id)).toEqual(['sk-030']);
|
|
144
|
+
expect(bundle.roadmap.matching_items.map((entry) => entry.item.id)).toContain('td-010');
|
|
145
|
+
expect(bundle.roadmap.matching_items.find((entry) => entry.item.id === 'td-010')?.item).not.toHaveProperty('notes');
|
|
146
|
+
expect(bundle.roadmap.matching_items.find((entry) => entry.item.id === 'td-010')?.item).not.toHaveProperty('risks');
|
|
147
|
+
expect(bundle.context.layer_docs).toContain('.tenbo/scopes/skill/layers/core-logic/intent.md');
|
|
148
|
+
expect(bundle.context.files_to_read).toContain('SKILL.md');
|
|
149
|
+
expect(bundle.warnings).toContainEqual(expect.objectContaining({ kind: 'stale_agent_context' }));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns candidates instead of overclaiming when a query has low overlap', () => {
|
|
153
|
+
const bundle = resolveFeatureContext(dir, 'improve billing invoices', {
|
|
154
|
+
now: new Date('2026-06-14T12:00:00.000Z'),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(bundle.recommendation.confidence).toBe('low');
|
|
158
|
+
expect(bundle.recommendation.scope).toBeNull();
|
|
159
|
+
expect(bundle.candidates.scopes.map((entry) => entry.id)).toContain('dashboard');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readLayerContent, readState } from './tenboFs';
|
|
4
|
+
import { comparePriority } from './priority';
|
|
5
|
+
import type { Item, Layer, Scope, Status } from '../../types';
|
|
6
|
+
|
|
7
|
+
export type ContextIntent = 'session' | 'feature' | 'item';
|
|
8
|
+
export type ContextConfidence = 'high' | 'medium' | 'low';
|
|
9
|
+
|
|
10
|
+
export interface ContextWarning {
|
|
11
|
+
kind: 'missing_agent_context' | 'stale_agent_context';
|
|
12
|
+
message: string;
|
|
13
|
+
path: string;
|
|
14
|
+
age_days?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContextItemSummary {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
status: Status;
|
|
21
|
+
description: string;
|
|
22
|
+
layer?: string;
|
|
23
|
+
layers?: string[];
|
|
24
|
+
affects?: string[];
|
|
25
|
+
type?: Item['type'];
|
|
26
|
+
priority?: Item['priority'];
|
|
27
|
+
goal_ref?: Item['goal_ref'];
|
|
28
|
+
files_to_read?: string[];
|
|
29
|
+
done_when?: string[];
|
|
30
|
+
risks?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ContextItemEntry {
|
|
34
|
+
scope: string;
|
|
35
|
+
item: ContextItemSummary;
|
|
36
|
+
score: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextLayerEntry {
|
|
40
|
+
scope: string;
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
score: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ContextScopeEntry {
|
|
47
|
+
id: string;
|
|
48
|
+
path: string;
|
|
49
|
+
description: string;
|
|
50
|
+
score: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FeatureContextBundle {
|
|
54
|
+
ok: true;
|
|
55
|
+
intent: 'feature';
|
|
56
|
+
query: string;
|
|
57
|
+
product: {
|
|
58
|
+
goals: Array<{ id: string; text: string }>;
|
|
59
|
+
non_goals: string[];
|
|
60
|
+
};
|
|
61
|
+
recommendation: {
|
|
62
|
+
confidence: ContextConfidence;
|
|
63
|
+
scope: string | null;
|
|
64
|
+
layers: string[];
|
|
65
|
+
goal_refs: string[];
|
|
66
|
+
};
|
|
67
|
+
candidates: {
|
|
68
|
+
scopes: ContextScopeEntry[];
|
|
69
|
+
layers: ContextLayerEntry[];
|
|
70
|
+
};
|
|
71
|
+
roadmap: {
|
|
72
|
+
active_items: ContextItemEntry[];
|
|
73
|
+
matching_items: ContextItemEntry[];
|
|
74
|
+
next_items: ContextItemEntry[];
|
|
75
|
+
};
|
|
76
|
+
context: {
|
|
77
|
+
layer_docs: string[];
|
|
78
|
+
files_to_read: string[];
|
|
79
|
+
};
|
|
80
|
+
warnings: ContextWarning[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ResolveOptions {
|
|
84
|
+
now?: Date;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Goal {
|
|
88
|
+
id: string;
|
|
89
|
+
text: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ScoredLayer {
|
|
93
|
+
scope: Scope;
|
|
94
|
+
layer: Layer;
|
|
95
|
+
score: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ScoredItemEntry {
|
|
99
|
+
scope: string;
|
|
100
|
+
item: Item;
|
|
101
|
+
score: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STOP_WORDS = new Set([
|
|
105
|
+
'a',
|
|
106
|
+
'an',
|
|
107
|
+
'and',
|
|
108
|
+
'are',
|
|
109
|
+
'as',
|
|
110
|
+
'be',
|
|
111
|
+
'build',
|
|
112
|
+
'can',
|
|
113
|
+
'for',
|
|
114
|
+
'from',
|
|
115
|
+
'help',
|
|
116
|
+
'how',
|
|
117
|
+
'i',
|
|
118
|
+
'in',
|
|
119
|
+
'into',
|
|
120
|
+
'is',
|
|
121
|
+
'it',
|
|
122
|
+
'me',
|
|
123
|
+
'of',
|
|
124
|
+
'on',
|
|
125
|
+
'or',
|
|
126
|
+
'our',
|
|
127
|
+
'the',
|
|
128
|
+
'this',
|
|
129
|
+
'to',
|
|
130
|
+
'we',
|
|
131
|
+
'with',
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const ACTIVE_STATUSES: readonly Status[] = ['now'];
|
|
135
|
+
const NEXT_STATUSES: readonly Status[] = ['now', 'next'];
|
|
136
|
+
|
|
137
|
+
function tenboDir(repoRoot: string): string {
|
|
138
|
+
return path.join(repoRoot, '.tenbo');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function repoRelative(...parts: string[]): string {
|
|
142
|
+
return path.join(...parts).split(path.sep).join('/');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function tokenize(text: string): Set<string> {
|
|
146
|
+
const tokens = text
|
|
147
|
+
.toLowerCase()
|
|
148
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
149
|
+
.split(/\s+/)
|
|
150
|
+
.map((token) => token.trim())
|
|
151
|
+
.filter((token) => token.length > 2 && !STOP_WORDS.has(token));
|
|
152
|
+
return new Set(tokens);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function overlapScore(queryTokens: Set<string>, text: string): number {
|
|
156
|
+
if (queryTokens.size === 0) return 0;
|
|
157
|
+
const textTokens = tokenize(text);
|
|
158
|
+
let score = 0;
|
|
159
|
+
for (const token of queryTokens) {
|
|
160
|
+
if (textTokens.has(token)) score += 1;
|
|
161
|
+
}
|
|
162
|
+
return score;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseGoals(overviewMd: string): Goal[] {
|
|
166
|
+
const goals: Goal[] = [];
|
|
167
|
+
const section = overviewMd.match(/## Product goals\s+([\s\S]*?)(?:\n## |\s*$)/i)?.[1] ?? '';
|
|
168
|
+
for (const line of section.split(/\r?\n/)) {
|
|
169
|
+
const match = line.match(/^\s*-\s+\*\*(g\d+)\*\*:\s*(.+)$/i);
|
|
170
|
+
if (match) goals.push({ id: match[1], text: match[2].trim() });
|
|
171
|
+
}
|
|
172
|
+
return goals;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseNonGoals(overviewMd: string): string[] {
|
|
176
|
+
const section = overviewMd.match(/## Non-goals\s+([\s\S]*?)(?:\n## |\s*$)/i)?.[1] ?? '';
|
|
177
|
+
return section
|
|
178
|
+
.split(/\r?\n/)
|
|
179
|
+
.map((line) => line.replace(/^\s*-\s*/, '').trim())
|
|
180
|
+
.filter(Boolean);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function itemText(item: Item): string {
|
|
184
|
+
return [
|
|
185
|
+
item.id,
|
|
186
|
+
item.title,
|
|
187
|
+
item.layer,
|
|
188
|
+
...(item.layers ?? []),
|
|
189
|
+
item.description,
|
|
190
|
+
...(item.done_when ?? []),
|
|
191
|
+
...(item.files_to_read ?? []),
|
|
192
|
+
...(item.risks ?? []),
|
|
193
|
+
].filter(Boolean).join(' ');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function layerText(repoRoot: string, scope: Scope, layer: Layer): string {
|
|
197
|
+
const content = readLayerContent(repoRoot, scope.id, layer.id);
|
|
198
|
+
return [
|
|
199
|
+
scope.id,
|
|
200
|
+
scope.path,
|
|
201
|
+
scope.description,
|
|
202
|
+
layer.id,
|
|
203
|
+
layer.name,
|
|
204
|
+
layer.description,
|
|
205
|
+
...(layer.files ?? []),
|
|
206
|
+
content.readme,
|
|
207
|
+
content.intentMd,
|
|
208
|
+
content.codeMapMd,
|
|
209
|
+
].join(' ');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function itemLayerIds(item: Item): string[] {
|
|
213
|
+
return [item.layer, ...(item.layers ?? []), ...(item.affects ?? [])].filter((value): value is string => Boolean(value));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function itemBoostForLayer(scope: Scope, layer: Layer, matchingItems: ScoredItemEntry[]): number {
|
|
217
|
+
let boost = 0;
|
|
218
|
+
for (const entry of matchingItems) {
|
|
219
|
+
if (entry.scope !== scope.id) continue;
|
|
220
|
+
for (const layerRef of itemLayerIds(entry.item)) {
|
|
221
|
+
const [refScope, refLayer] = layerRef.includes(':') ? layerRef.split(':', 2) : [scope.id, layerRef];
|
|
222
|
+
if (refScope === scope.id && refLayer === layer.id) boost += entry.score * 2;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return boost;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function scoreLayers(repoRoot: string, queryTokens: Set<string>, scopes: Scope[], matchingItems: ScoredItemEntry[]): ScoredLayer[] {
|
|
229
|
+
const scored: ScoredLayer[] = [];
|
|
230
|
+
for (const scope of scopes) {
|
|
231
|
+
for (const layer of scope.layers) {
|
|
232
|
+
scored.push({
|
|
233
|
+
scope,
|
|
234
|
+
layer,
|
|
235
|
+
score: overlapScore(queryTokens, layerText(repoRoot, scope, layer)) + itemBoostForLayer(scope, layer, matchingItems),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return scored.sort((a, b) => b.score - a.score || a.scope.id.localeCompare(b.scope.id) || a.layer.id.localeCompare(b.layer.id));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function scoreItems(queryTokens: Set<string>, scopes: Scope[]): ScoredItemEntry[] {
|
|
243
|
+
const out: ScoredItemEntry[] = [];
|
|
244
|
+
for (const scope of scopes) {
|
|
245
|
+
for (const item of scope.items) {
|
|
246
|
+
const score = overlapScore(queryTokens, itemText(item));
|
|
247
|
+
if (score > 0) out.push({ scope: scope.id, item, score });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out.sort((a, b) => b.score - a.score || comparePriority(a.item, b.item) || a.item.id.localeCompare(b.item.id));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function summarizeItem(item: Item, detail: 'brief' | 'execution'): ContextItemSummary {
|
|
254
|
+
return {
|
|
255
|
+
id: item.id,
|
|
256
|
+
title: item.title,
|
|
257
|
+
status: item.status,
|
|
258
|
+
description: item.description,
|
|
259
|
+
...(item.layer ? { layer: item.layer } : {}),
|
|
260
|
+
...(item.layers ? { layers: item.layers } : {}),
|
|
261
|
+
...(item.affects ? { affects: item.affects } : {}),
|
|
262
|
+
...(item.type ? { type: item.type } : {}),
|
|
263
|
+
...(item.priority ? { priority: item.priority } : {}),
|
|
264
|
+
...(item.goal_ref ? { goal_ref: item.goal_ref } : {}),
|
|
265
|
+
...(item.files_to_read ? { files_to_read: item.files_to_read } : {}),
|
|
266
|
+
...(detail === 'execution' && item.done_when ? { done_when: item.done_when } : {}),
|
|
267
|
+
...(detail === 'execution' && item.risks ? { risks: item.risks } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function publicItemEntry(entry: ScoredItemEntry, detail: 'brief' | 'execution'): ContextItemEntry {
|
|
272
|
+
return {
|
|
273
|
+
scope: entry.scope,
|
|
274
|
+
item: summarizeItem(entry.item, detail),
|
|
275
|
+
score: entry.score,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function goalRefsForItem(item: Item): string[] {
|
|
280
|
+
if (!item.goal_ref || item.goal_ref === 'exploratory') return [];
|
|
281
|
+
return item.goal_ref;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function unique<T>(values: T[]): T[] {
|
|
285
|
+
return Array.from(new Set(values));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function confidenceForScore(score: number): ContextConfidence {
|
|
289
|
+
if (score >= 3) return 'high';
|
|
290
|
+
if (score >= 2) return 'medium';
|
|
291
|
+
return 'low';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function scopeCandidates(scopes: Scope[], scoredLayers: ScoredLayer[], matchingItems: ScoredItemEntry[]): ContextScopeEntry[] {
|
|
295
|
+
return scopes.map((scope) => {
|
|
296
|
+
const layerScore = scoredLayers
|
|
297
|
+
.filter((entry) => entry.scope.id === scope.id)
|
|
298
|
+
.reduce((sum, entry) => sum + entry.score, 0);
|
|
299
|
+
const itemScore = matchingItems
|
|
300
|
+
.filter((entry) => entry.scope === scope.id)
|
|
301
|
+
.reduce((sum, entry) => sum + entry.score, 0);
|
|
302
|
+
return {
|
|
303
|
+
id: scope.id,
|
|
304
|
+
path: scope.path,
|
|
305
|
+
description: scope.description,
|
|
306
|
+
score: layerScore + itemScore,
|
|
307
|
+
};
|
|
308
|
+
}).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function roadmapEntriesForScope(scope: Scope, statuses: readonly Status[]): ScoredItemEntry[] {
|
|
312
|
+
return scope.items
|
|
313
|
+
.filter((item) => statuses.includes(item.status))
|
|
314
|
+
.map((item) => ({ scope: scope.id, item, score: 0 }))
|
|
315
|
+
.sort((a, b) => comparePriority(a.item, b.item) || a.item.id.localeCompare(b.item.id));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function agentContextWarnings(repoRoot: string, now: Date): ContextWarning[] {
|
|
319
|
+
const contextPath = path.join(tenboDir(repoRoot), 'agent-context.md');
|
|
320
|
+
const relPath = '.tenbo/agent-context.md';
|
|
321
|
+
if (!existsSync(contextPath)) {
|
|
322
|
+
return [{ kind: 'missing_agent_context', path: relPath, message: 'agent context digest is missing' }];
|
|
323
|
+
}
|
|
324
|
+
const ageDays = Math.floor((now.getTime() - statSync(contextPath).mtimeMs) / 86_400_000);
|
|
325
|
+
if (ageDays > 7) {
|
|
326
|
+
return [{
|
|
327
|
+
kind: 'stale_agent_context',
|
|
328
|
+
path: relPath,
|
|
329
|
+
age_days: ageDays,
|
|
330
|
+
message: `agent context digest is ${ageDays} days old`,
|
|
331
|
+
}];
|
|
332
|
+
}
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function resolveFeatureContext(repoRoot: string, query: string, opts: ResolveOptions = {}): FeatureContextBundle {
|
|
337
|
+
const now = opts.now ?? new Date();
|
|
338
|
+
const state = readState(repoRoot);
|
|
339
|
+
const queryTokens = tokenize(query);
|
|
340
|
+
const goals = parseGoals(state.workspaceContent.overviewMd);
|
|
341
|
+
const nonGoals = parseNonGoals(state.workspaceContent.overviewMd);
|
|
342
|
+
const matchingItems = scoreItems(queryTokens, state.scopes);
|
|
343
|
+
const scoredLayers = scoreLayers(repoRoot, queryTokens, state.scopes, matchingItems);
|
|
344
|
+
const candidates = scopeCandidates(state.scopes, scoredLayers, matchingItems);
|
|
345
|
+
const topLayer = scoredLayers[0];
|
|
346
|
+
const confidence = confidenceForScore(topLayer?.score ?? 0);
|
|
347
|
+
const selectedScope = confidence === 'low'
|
|
348
|
+
? null
|
|
349
|
+
: state.scopes.find((scope) => scope.id === topLayer.scope.id) ?? null;
|
|
350
|
+
const selectedLayers = selectedScope
|
|
351
|
+
? scoredLayers
|
|
352
|
+
.filter((entry) => entry.scope.id === selectedScope.id && entry.score > 0)
|
|
353
|
+
.filter((entry) => entry.score === topLayer.score)
|
|
354
|
+
.slice(0, 3)
|
|
355
|
+
.map((entry) => entry.layer.id)
|
|
356
|
+
: [];
|
|
357
|
+
const activeItems = selectedScope ? roadmapEntriesForScope(selectedScope, ACTIVE_STATUSES) : [];
|
|
358
|
+
const nextItems = selectedScope ? roadmapEntriesForScope(selectedScope, NEXT_STATUSES) : [];
|
|
359
|
+
const goalRefs = unique([
|
|
360
|
+
...activeItems.flatMap((entry) => goalRefsForItem(entry.item)),
|
|
361
|
+
...matchingItems.flatMap((entry) => goalRefsForItem(entry.item)),
|
|
362
|
+
...goals
|
|
363
|
+
.map((goal) => ({ goal, score: overlapScore(queryTokens, goal.text) }))
|
|
364
|
+
.filter((entry) => entry.score > 0)
|
|
365
|
+
.map((entry) => entry.goal.id),
|
|
366
|
+
]);
|
|
367
|
+
const layerDocs = selectedScope
|
|
368
|
+
? selectedLayers.flatMap((layerId) => [
|
|
369
|
+
repoRelative('.tenbo', 'scopes', selectedScope.id, 'layers', layerId, 'intent.md'),
|
|
370
|
+
repoRelative('.tenbo', 'scopes', selectedScope.id, 'layers', layerId, 'code-map.md'),
|
|
371
|
+
])
|
|
372
|
+
: [];
|
|
373
|
+
const layerFiles = selectedScope
|
|
374
|
+
? selectedScope.layers
|
|
375
|
+
.filter((layer) => selectedLayers.includes(layer.id))
|
|
376
|
+
.flatMap((layer) => layer.files ?? [])
|
|
377
|
+
: [];
|
|
378
|
+
const filesToRead = unique([
|
|
379
|
+
...layerFiles,
|
|
380
|
+
...activeItems.flatMap((entry) => entry.item.files_to_read ?? []),
|
|
381
|
+
...matchingItems.flatMap((entry) => entry.item.files_to_read ?? []),
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
intent: 'feature',
|
|
387
|
+
query,
|
|
388
|
+
product: { goals, non_goals: nonGoals },
|
|
389
|
+
recommendation: {
|
|
390
|
+
confidence,
|
|
391
|
+
scope: selectedScope?.id ?? null,
|
|
392
|
+
layers: selectedLayers,
|
|
393
|
+
goal_refs: goalRefs,
|
|
394
|
+
},
|
|
395
|
+
candidates: {
|
|
396
|
+
scopes: candidates,
|
|
397
|
+
layers: scoredLayers.slice(0, 5).map((entry) => ({
|
|
398
|
+
scope: entry.scope.id,
|
|
399
|
+
id: entry.layer.id,
|
|
400
|
+
name: entry.layer.name,
|
|
401
|
+
score: entry.score,
|
|
402
|
+
})),
|
|
403
|
+
},
|
|
404
|
+
roadmap: {
|
|
405
|
+
active_items: activeItems.map((entry) => publicItemEntry(entry, 'execution')),
|
|
406
|
+
matching_items: matchingItems.slice(0, 5).map((entry) => publicItemEntry(entry, 'brief')),
|
|
407
|
+
next_items: nextItems.map((entry) => publicItemEntry(entry, 'execution')),
|
|
408
|
+
},
|
|
409
|
+
context: {
|
|
410
|
+
layer_docs: layerDocs,
|
|
411
|
+
files_to_read: filesToRead,
|
|
412
|
+
},
|
|
413
|
+
warnings: agentContextWarnings(repoRoot, now),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
@@ -3,6 +3,30 @@ import { collectAll } from './collectAll';
|
|
|
3
3
|
import { DEFAULT_HEALTH_CONFIG } from './config';
|
|
4
4
|
import { findRepoRoot } from '../repoRoot';
|
|
5
5
|
import type { Scope } from '../../../types';
|
|
6
|
+
import { writeFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import type { HealthConfig } from './config';
|
|
10
|
+
import type { Signal } from './types';
|
|
11
|
+
|
|
12
|
+
const NON_DEAD_CODE_SIGNALS: Signal[] = [
|
|
13
|
+
'hotspot-files',
|
|
14
|
+
'aging-todos',
|
|
15
|
+
'doc-drift',
|
|
16
|
+
'test-coverage',
|
|
17
|
+
'architecture-compliance',
|
|
18
|
+
'redundancy',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function deadCodeOnlyConfig(layerId: string): HealthConfig {
|
|
22
|
+
return {
|
|
23
|
+
...DEFAULT_HEALTH_CONFIG,
|
|
24
|
+
ignore: {
|
|
25
|
+
...DEFAULT_HEALTH_CONFIG.ignore,
|
|
26
|
+
layer_signals: { [layerId]: NON_DEAD_CODE_SIGNALS },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
describe('collectAll', () => {
|
|
8
32
|
const repoRoot = findRepoRoot(process.cwd());
|
|
@@ -46,4 +70,41 @@ describe('collectAll', () => {
|
|
|
46
70
|
const findings = await collectAll(repoRoot, scope, cfg as any);
|
|
47
71
|
expect(findings).toEqual([]);
|
|
48
72
|
});
|
|
73
|
+
|
|
74
|
+
it('does not flag layer files imported by uncovered scope source files', async () => {
|
|
75
|
+
const root = mkdtempSync(path.join(tmpdir(), 'collect-dead-code-'));
|
|
76
|
+
mkdirSync(path.join(root, 'apps/editor/src/domains/canvas/hooks'), { recursive: true });
|
|
77
|
+
mkdirSync(path.join(root, 'apps/editor/src/shared'), { recursive: true });
|
|
78
|
+
writeFileSync(path.join(root, 'tsconfig.json'), JSON.stringify({
|
|
79
|
+
compilerOptions: { module: 'esnext', target: 'es2020', moduleResolution: 'bundler', strict: false },
|
|
80
|
+
include: ['apps/editor/src'],
|
|
81
|
+
}));
|
|
82
|
+
writeFileSync(path.join(root, 'apps/editor/tsconfig.json'), JSON.stringify({
|
|
83
|
+
extends: '../../tsconfig.json',
|
|
84
|
+
compilerOptions: {
|
|
85
|
+
baseUrl: '.',
|
|
86
|
+
paths: { '@/*': ['./src/*'] },
|
|
87
|
+
},
|
|
88
|
+
include: ['src'],
|
|
89
|
+
}));
|
|
90
|
+
writeFileSync(path.join(root, 'apps/editor/src/domains/canvas/hooks/useCanvas.ts'), `export const useCanvas = () => null;`);
|
|
91
|
+
writeFileSync(path.join(root, 'apps/editor/src/shared/CanvasConsumer.ts'), `import { useCanvas } from '@/domains/canvas/hooks/useCanvas';\nexport const consumer = useCanvas;`);
|
|
92
|
+
const scope: Scope = {
|
|
93
|
+
id: 'editor',
|
|
94
|
+
path: 'apps/editor',
|
|
95
|
+
description: '',
|
|
96
|
+
layers: [{
|
|
97
|
+
id: 'visual-canvas',
|
|
98
|
+
name: 'Visual Canvas',
|
|
99
|
+
description: '',
|
|
100
|
+
files: ['src/domains/canvas/**'],
|
|
101
|
+
dependencies: { inbound: [], outbound: [], external: [] },
|
|
102
|
+
}],
|
|
103
|
+
items: [],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const findings = await collectAll(root, scope, deadCodeOnlyConfig('visual-canvas'));
|
|
107
|
+
expect(findings.find(f => f.signal === 'dead-code' && f.target === 'apps/editor/src/domains/canvas/hooks/useCanvas.ts')).toBeUndefined();
|
|
108
|
+
rmSync(root, { recursive: true, force: true });
|
|
109
|
+
});
|
|
49
110
|
});
|