devworkspace 0.3.2 → 0.4.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/CHANGELOG.md +57 -0
- package/README.md +24 -0
- package/dist/agents/claude/content.js +27 -4
- package/dist/agents/claude/content.js.map +1 -1
- package/dist/cli/args.d.ts +4 -0
- package/dist/cli/args.js +17 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/help.js +43 -4
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/render.d.ts +2 -0
- package/dist/cli/render.js +40 -1
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/run.js +78 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/protocol.d.ts +35 -0
- package/dist/mcp/protocol.js +175 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +25 -0
- package/dist/mcp/server.js +163 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/setup.d.ts +54 -0
- package/dist/mcp/setup.js +288 -0
- package/dist/mcp/setup.js.map +1 -0
- package/dist/mcp/tools.d.ts +8 -0
- package/dist/mcp/tools.js +446 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/mcp/types.d.ts +80 -0
- package/dist/mcp/types.js +15 -0
- package/dist/mcp/types.js.map +1 -0
- package/dist/services/taskContext.d.ts +28 -5
- package/dist/services/taskContext.js +420 -40
- package/dist/services/taskContext.js.map +1 -1
- package/docs/agents.md +32 -0
- package/docs/context-accuracy-report.md +60 -0
- package/docs/context-evaluation.md +170 -0
- package/docs/mcp.md +264 -0
- package/package.json +3 -1
|
@@ -2,6 +2,9 @@ import { type CommandEntry, type Fact } from '../config/schemas.js';
|
|
|
2
2
|
import type { TaskType } from '../tasks/task.js';
|
|
3
3
|
import { type ContextResult } from './knowledge.js';
|
|
4
4
|
import type { ServiceOptions } from './common.js';
|
|
5
|
+
export declare const CONTEXT_ROLES: readonly ["explorer", "tester", "reviewer"];
|
|
6
|
+
export type ContextRole = (typeof CONTEXT_ROLES)[number];
|
|
7
|
+
export declare function isContextRole(v: unknown): v is ContextRole;
|
|
5
8
|
export interface ContextItem {
|
|
6
9
|
kind: 'fact' | 'check' | 'decision' | 'document' | 'rule';
|
|
7
10
|
id: string;
|
|
@@ -48,12 +51,32 @@ export interface TaskContextResult {
|
|
|
48
51
|
decisions: [number, number];
|
|
49
52
|
documents: [number, number];
|
|
50
53
|
};
|
|
54
|
+
role?: ContextRole;
|
|
55
|
+
roleGuidance?: string[];
|
|
56
|
+
roleFindings?: {
|
|
57
|
+
entryPoints?: string[];
|
|
58
|
+
graphNodes?: {
|
|
59
|
+
id: string;
|
|
60
|
+
status: string;
|
|
61
|
+
path: string | null;
|
|
62
|
+
symbol: string | null;
|
|
63
|
+
}[];
|
|
64
|
+
features?: {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
status?: string;
|
|
68
|
+
}[];
|
|
69
|
+
testFrameworks?: string[];
|
|
70
|
+
zeroTestSafeguards?: string[];
|
|
71
|
+
diffNotice?: string;
|
|
72
|
+
};
|
|
51
73
|
}
|
|
52
|
-
/**
|
|
53
|
-
* Task-aware context. Selection is deterministic (sorted inputs, no clocks, no randomness) and rule-based: the request is
|
|
54
|
-
* classified, its keywords select facts/decisions/packages, and the type decides which broader groups come along.
|
|
55
|
-
* Safety rules are included for every type; the per-package "not run automatically" list whenever checks are included.
|
|
56
|
-
*/
|
|
57
74
|
export declare function getTaskContext(o: ServiceOptions, opts: {
|
|
58
75
|
request: string;
|
|
76
|
+
role?: ContextRole;
|
|
77
|
+
}): Promise<TaskContextResult>;
|
|
78
|
+
export declare function getAgentContextSlice(o: ServiceOptions, opts: {
|
|
79
|
+
request: string;
|
|
80
|
+
role: ContextRole;
|
|
81
|
+
base?: TaskContextResult;
|
|
59
82
|
}): Promise<TaskContextResult>;
|
|
@@ -10,14 +10,125 @@ import { pathExists } from '../utils/fs.js';
|
|
|
10
10
|
import { classifyCommand, readManifestEvidence } from '../verification/safety.js';
|
|
11
11
|
import { dirtyFiles, packageDirs } from '../verification/scope.js';
|
|
12
12
|
import { SAFETY_RULES } from './knowledge.js';
|
|
13
|
-
|
|
13
|
+
import { loadFeatures } from './features.js';
|
|
14
|
+
import { loadGraphData, findMatchingStartNode } from './graph.js';
|
|
15
|
+
export const CONTEXT_ROLES = ['explorer', 'tester', 'reviewer'];
|
|
16
|
+
export function isContextRole(v) {
|
|
17
|
+
return typeof v === 'string' && CONTEXT_ROLES.includes(v);
|
|
18
|
+
}
|
|
19
|
+
const STOP = new Set([
|
|
20
|
+
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'does', 'how',
|
|
21
|
+
'what', 'where', 'why', 'when', 'which', 'fix', 'add', 'bug', 'make', 'new',
|
|
22
|
+
'can', 'are', 'not', 'our', 'all', 'use', 'get', 'set', 'let', 'may'
|
|
23
|
+
]);
|
|
24
|
+
const WEAK_TERMS = new Set(['error', 'errors', 'check', 'checks', 'data', 'improve', 'update', 'run', 'test', 'handle', 'handling', 'code']);
|
|
25
|
+
const PATH_STRUCTURAL_TERMS = new Set([
|
|
26
|
+
'backend', 'frontend', 'service', 'services', 'controller', 'controllers',
|
|
27
|
+
'worker', 'workers', 'dto', 'dtos', 'src', 'modules', 'app', 'tests',
|
|
28
|
+
'unit', 'integration', 'test', 'spec', 'ts', 'tsx', 'js', 'jsx', 'json', 'md'
|
|
29
|
+
]);
|
|
14
30
|
const CLIP = 400;
|
|
15
31
|
const SECURITY_WORDS = /auth|tenant|permission|rbac|security|isolation|role|session|ownership/i;
|
|
16
32
|
const ISSUE_KEY = /^(?:issue|limit|known|bug|risk)/i;
|
|
17
33
|
/** Facts that describe the stack and layout; implementation-style tasks need them, a lookup question does not. */
|
|
18
34
|
const STACK_KEY = /^(?:stack\.|repo\.packages|repo\.monorepo|repo\.entry-points|convention|style|arch|pattern|testing)/i;
|
|
19
35
|
const BROAD_TYPES = ['FEATURE', 'REFACTOR', 'ARCHITECTURE', 'TESTING', 'PERFORMANCE', 'MAINTENANCE', 'RELEASE'];
|
|
20
|
-
|
|
36
|
+
/** Normalizes words by stripping standard English inflectional suffixes deterministically. */
|
|
37
|
+
function normalizeStem(w) {
|
|
38
|
+
const s = w.toLowerCase().trim();
|
|
39
|
+
if (s.endsWith('ies') && s.length > 4)
|
|
40
|
+
return s.slice(0, -3) + 'y';
|
|
41
|
+
if (s.endsWith('es') && s.length > 4 && /[sxz]|ch|sh$/.test(s.slice(0, -2)))
|
|
42
|
+
return s.slice(0, -2);
|
|
43
|
+
if (s.endsWith('s') && s.length > 3 && !s.endsWith('ss'))
|
|
44
|
+
return s.slice(0, -1);
|
|
45
|
+
if (s.endsWith('ing') && s.length > 5)
|
|
46
|
+
return s.slice(0, -3);
|
|
47
|
+
if (s.endsWith('tion') && s.length > 6)
|
|
48
|
+
return s.slice(0, -4);
|
|
49
|
+
if (s.endsWith('ed') && s.length > 4)
|
|
50
|
+
return s.slice(0, -2);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
/** Explicit domain synonym dictionary grounded in engineering tasks and repository knowledge. */
|
|
54
|
+
const DOMAIN_SYNONYMS = {
|
|
55
|
+
// Database, migrations, schema and persistence
|
|
56
|
+
table: ['database', 'schema', 'orm', 'migration', 'sql'],
|
|
57
|
+
column: ['database', 'schema', 'migration', 'sql'],
|
|
58
|
+
schema: ['database', 'orm', 'migration', 'sql', 'drizzle', 'postgres'],
|
|
59
|
+
migration: ['database', 'schema', 'migrate', 'sql', 'drizzle'],
|
|
60
|
+
migrate: ['database', 'schema', 'migration', 'sql'],
|
|
61
|
+
sql: ['database', 'postgres', 'drizzle', 'query'],
|
|
62
|
+
db: ['database', 'postgres', 'drizzle', 'orm'],
|
|
63
|
+
database: ['db', 'schema', 'orm', 'postgres', 'drizzle'],
|
|
64
|
+
drizzle: ['database', 'orm', 'schema'],
|
|
65
|
+
postgres: ['database', 'sql'],
|
|
66
|
+
// Authentication, tenancy, and authorization
|
|
67
|
+
auth: ['jwt', 'session', 'security', 'tenant', 'permission'],
|
|
68
|
+
jwt: ['auth', 'session', 'token', 'security'],
|
|
69
|
+
password: ['hash', 'argon2', 'auth', 'security'],
|
|
70
|
+
hash: ['password', 'argon2', 'security'],
|
|
71
|
+
tenant: ['tenancy', 'isolation', 'security', 'auth'],
|
|
72
|
+
tenancy: ['tenant', 'isolation', 'security'],
|
|
73
|
+
isolation: ['tenant', 'tenancy', 'security'],
|
|
74
|
+
// Billing and financial
|
|
75
|
+
invoice: ['billing', 'cents', 'precision'],
|
|
76
|
+
billing: ['invoice', 'cents', 'precision'],
|
|
77
|
+
cents: ['billing', 'invoice', 'precision'],
|
|
78
|
+
payment: ['stripe', 'gateway'],
|
|
79
|
+
// API, HTTP, and Backend Services
|
|
80
|
+
endpoint: ['api', 'controller', 'service', 'dto'],
|
|
81
|
+
api: ['endpoint', 'controller', 'service', 'dto'],
|
|
82
|
+
supplier: ['supplier', 'dto', 'service'],
|
|
83
|
+
controller: ['service', 'dto', 'api', 'endpoint'],
|
|
84
|
+
dto: ['validation', 'typebox', 'schema', 'controller'],
|
|
85
|
+
// Frontend and UI
|
|
86
|
+
component: ['ui', 'frontend', 'page', 'react'],
|
|
87
|
+
pagination: ['ui', 'component', 'frontend', 'table'],
|
|
88
|
+
frontend: ['ui', 'component', 'react'],
|
|
89
|
+
ui: ['component', 'frontend', 'react'],
|
|
90
|
+
react: ['ui', 'component', 'frontend'],
|
|
91
|
+
// Inventory and Stock
|
|
92
|
+
stock: ['inventory', 'product', 'service'],
|
|
93
|
+
inventory: ['stock', 'product', 'service'],
|
|
94
|
+
product: ['inventory', 'stock', 'service'],
|
|
95
|
+
sku: ['product', 'inventory', 'barcode'],
|
|
96
|
+
barcode: ['sku', 'product', 'inventory']
|
|
97
|
+
};
|
|
98
|
+
/** Extracts words from text without punctuation. */
|
|
99
|
+
const words = (text) => [...new Set((text.toLowerCase().match(/[a-z0-9_][a-z0-9_.-]{1,}/g) ?? []).filter((w) => !STOP.has(w)))].sort();
|
|
100
|
+
/** Expands terms using word stemming and explicit domain synonym clusters. */
|
|
101
|
+
function expandQueryTerms(rawWords) {
|
|
102
|
+
const expanded = new Set(rawWords);
|
|
103
|
+
for (const w of rawWords) {
|
|
104
|
+
const stem = normalizeStem(w);
|
|
105
|
+
if (stem.length >= 2)
|
|
106
|
+
expanded.add(stem);
|
|
107
|
+
const syns = DOMAIN_SYNONYMS[w] ?? DOMAIN_SYNONYMS[stem];
|
|
108
|
+
if (syns) {
|
|
109
|
+
for (const s of syns)
|
|
110
|
+
expanded.add(s);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...expanded].sort();
|
|
114
|
+
}
|
|
115
|
+
/** Matches terms against text with token/boundary precision. */
|
|
116
|
+
function matchesQuery(text, terms) {
|
|
117
|
+
const lower = text.toLowerCase();
|
|
118
|
+
const matched = [];
|
|
119
|
+
for (const term of terms) {
|
|
120
|
+
if (term.includes('.') || term.includes('/')) {
|
|
121
|
+
if (lower.includes(term))
|
|
122
|
+
matched.push(term);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const re = new RegExp(`(?:^|[\\s._/:-])${term}(?:$|[\\s._/:-])`, 'i');
|
|
126
|
+
if (re.test(lower))
|
|
127
|
+
matched.push(term);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return matched;
|
|
131
|
+
}
|
|
21
132
|
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
22
133
|
/** Which context documents are worth pointing at for a task type. */
|
|
23
134
|
const DOCS_BY_TYPE = {
|
|
@@ -35,33 +146,106 @@ const DOCS_BY_TYPE = {
|
|
|
35
146
|
* classified, its keywords select facts/decisions/packages, and the type decides which broader groups come along.
|
|
36
147
|
* Safety rules are included for every type; the per-package "not run automatically" list whenever checks are included.
|
|
37
148
|
*/
|
|
38
|
-
|
|
149
|
+
async function buildBaseTaskContext(o, request) {
|
|
39
150
|
const ws = await requireWorkspace(o.cwd);
|
|
40
151
|
const project = await loadProject(ws);
|
|
41
|
-
const cls = classifyRequest(
|
|
152
|
+
const cls = classifyRequest(request);
|
|
42
153
|
const type = cls.type;
|
|
43
|
-
const kw = words(
|
|
154
|
+
const kw = words(request);
|
|
155
|
+
const expandedTerms = expandQueryTerms(kw);
|
|
44
156
|
const included = [];
|
|
45
|
-
const hit = (text) =>
|
|
157
|
+
const hit = (text) => matchesQuery(text, expandedTerms);
|
|
158
|
+
// Packages and the testing contract
|
|
159
|
+
let allChecks = [];
|
|
160
|
+
if (await pathExists(ws.commands))
|
|
161
|
+
allChecks = (await readVersioned(ws.commands, 'commands', SCHEMA_VERSION, validateCommands)).data.checks;
|
|
162
|
+
const pkgReason = new Map();
|
|
163
|
+
for (const d of packageDirs(allChecks)) {
|
|
164
|
+
const m = matchesQuery(request, [d]);
|
|
165
|
+
if (m.length > 0)
|
|
166
|
+
pkgReason.set(d, `request names "${m[0]}"`);
|
|
167
|
+
}
|
|
168
|
+
// Enhanced package discovery from domain context when not explicitly named
|
|
169
|
+
if (pkgReason.size === 0 && packageDirs(allChecks).length > 1) {
|
|
170
|
+
const hasBackend = matchesQuery(request, ['api', 'endpoint', 'endpoints', 'controller', 'controllers', 'dto', 'dtos', 'database', 'migration', 'migrations', 'migrate', 'sql', 'postgres', 'drizzle', 'orm', 'fastify', 'server', 'jwt', 'argon2', 'service', 'services', 'invoice']).length > 0;
|
|
171
|
+
const hasFrontend = matchesQuery(request, ['ui', 'component', 'components', 'frontend', 'page', 'pages', 'pagination', 'react', 'vite', 'view', 'views', 'button', 'modal']).length > 0;
|
|
172
|
+
const available = packageDirs(allChecks);
|
|
173
|
+
if (hasBackend && !hasFrontend && available.includes('backend')) {
|
|
174
|
+
pkgReason.set('backend', 'inferred from backend domain terminology');
|
|
175
|
+
}
|
|
176
|
+
else if (hasFrontend && !hasBackend && available.includes('frontend')) {
|
|
177
|
+
pkgReason.set('frontend', 'inferred from frontend UI terminology');
|
|
178
|
+
}
|
|
179
|
+
else if (hasBackend && hasFrontend) {
|
|
180
|
+
if (available.includes('backend'))
|
|
181
|
+
pkgReason.set('backend', 'request spans backend');
|
|
182
|
+
if (available.includes('frontend'))
|
|
183
|
+
pkgReason.set('frontend', 'request spans frontend');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// dirtyFiles already leaves out documentation and tool-managed files, so a docs-only change selects no package.
|
|
187
|
+
// Only worth the Git calls when the request did not already pick a package and there is more than one to choose from.
|
|
188
|
+
const dirty = type !== 'QUESTION' && type !== 'DOCUMENTATION' && pkgReason.size === 0 && packageDirs(allChecks).length > 1 ? await dirtyFiles(ws.root) : null;
|
|
189
|
+
for (const f of Object.keys(dirty?.files ?? {})) {
|
|
190
|
+
const d = packageDirs(allChecks).find((x) => f === x || f.startsWith(`${x}/`));
|
|
191
|
+
if (d && !pkgReason.has(d))
|
|
192
|
+
pkgReason.set(d, 'has uncommitted code changes');
|
|
193
|
+
}
|
|
46
194
|
// Facts
|
|
47
195
|
const all = (await loadFacts(ws)).facts;
|
|
48
196
|
const picked = new Map();
|
|
197
|
+
const isSinglePackage = pkgReason.size === 1;
|
|
198
|
+
const targetPkg = isSinglePackage ? [...pkgReason.keys()][0] : null;
|
|
49
199
|
for (const f of all) {
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
200
|
+
// If request confidently targets a single package, avoid pulling in unrelated package facts
|
|
201
|
+
if (targetPkg === 'backend') {
|
|
202
|
+
if (f.source.startsWith('frontend/') || f.key === 'stack.ui' || f.key.startsWith('convention.frontend')) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (targetPkg === 'frontend') {
|
|
207
|
+
if (f.source.startsWith('backend/') || f.key === 'stack.database' || f.key === 'stack.orm' || f.key.startsWith('testing.db')) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Match terms against fact key and value; only match source path against specific non-structural request words
|
|
212
|
+
const mText = hit(`${f.key} ${f.value}`);
|
|
213
|
+
const mSource = matchesQuery(f.source, kw.filter((w) => !PATH_STRUCTURAL_TERMS.has(w)));
|
|
214
|
+
const m = [...new Set([...mText, ...mSource])];
|
|
215
|
+
const nonWeak = m.filter(w => !WEAK_TERMS.has(w));
|
|
216
|
+
if (nonWeak.length > 0)
|
|
217
|
+
picked.set(f.key, `matches "${nonWeak.slice(0, 3).join('", "')}"`);
|
|
53
218
|
}
|
|
54
219
|
const group = (re, why, also) => {
|
|
55
|
-
for (const f of all)
|
|
56
|
-
if (
|
|
220
|
+
for (const f of all) {
|
|
221
|
+
if (picked.has(f.key))
|
|
222
|
+
continue;
|
|
223
|
+
if (targetPkg === 'backend' && (f.source.startsWith('frontend/') || f.key === 'stack.ui' || f.key.startsWith('convention.frontend')))
|
|
224
|
+
continue;
|
|
225
|
+
if (targetPkg === 'frontend' && (f.source.startsWith('backend/') || f.key === 'stack.database' || f.key === 'stack.orm' || f.key.startsWith('testing.db')))
|
|
226
|
+
continue;
|
|
227
|
+
if (re.test(f.key) || also?.(f))
|
|
57
228
|
picked.set(f.key, why);
|
|
229
|
+
}
|
|
58
230
|
};
|
|
59
|
-
|
|
60
|
-
|
|
231
|
+
// Package layout facts: include in multi-package/cross-package/uncertain contexts, omit global entry points in single-package tasks
|
|
232
|
+
if (type !== 'QUESTION' && type !== 'DOCUMENTATION') {
|
|
233
|
+
if (!isSinglePackage || type === 'ARCHITECTURE') {
|
|
234
|
+
group(/^repo\.(?:packages|entry-points|monorepo)$/, `package layout (${type})`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
61
237
|
if (type === 'BUG' || type === 'SECURITY')
|
|
62
238
|
group(ISSUE_KEY, 'known issue / limitation', (f) => f.status === 'STALE' && f.sourceType !== 'detector');
|
|
63
|
-
if (BROAD_TYPES.includes(type))
|
|
64
|
-
group(
|
|
239
|
+
if (BROAD_TYPES.includes(type)) {
|
|
240
|
+
group(/^stack\.ecosystem$/, `stack and conventions (${type})`);
|
|
241
|
+
if (targetPkg === 'backend' || !isSinglePackage) {
|
|
242
|
+
group(/^(?:arch\.modules|convention\.service-layer)$/, `architecture conventions (${type})`);
|
|
243
|
+
group(/^(?:stack\.framework|stack\.database|stack\.orm)$/, `backend stack (${type})`);
|
|
244
|
+
}
|
|
245
|
+
if (targetPkg === 'frontend' || !isSinglePackage) {
|
|
246
|
+
group(/^(?:convention\.frontend-components|stack\.ui)$/, `frontend stack (${type})`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
65
249
|
if (type === 'SECURITY')
|
|
66
250
|
group(/^$/, 'security / authorization rule', (f) => SECURITY_WORDS.test(`${f.key} ${f.value}`));
|
|
67
251
|
if (type === 'DOCUMENTATION')
|
|
@@ -85,24 +269,6 @@ export async function getTaskContext(o, opts) {
|
|
|
85
269
|
facts.push(clip ? { ...f, value: f.value.slice(0, CLIP), clipped: f.value.length - CLIP } : f);
|
|
86
270
|
included.push({ kind: 'fact', id: f.key, reason: picked.get(f.key) });
|
|
87
271
|
}
|
|
88
|
-
// Packages and the testing contract
|
|
89
|
-
let allChecks = [];
|
|
90
|
-
if (await pathExists(ws.commands))
|
|
91
|
-
allChecks = (await readVersioned(ws.commands, 'commands', SCHEMA_VERSION, validateCommands)).data.checks;
|
|
92
|
-
const pkgReason = new Map();
|
|
93
|
-
for (const d of packageDirs(allChecks)) {
|
|
94
|
-
const m = hit(d.toLowerCase());
|
|
95
|
-
if (m.length > 0)
|
|
96
|
-
pkgReason.set(d, `request names "${m[0]}"`);
|
|
97
|
-
}
|
|
98
|
-
// dirtyFiles already leaves out documentation and tool-managed files, so a docs-only change selects no package.
|
|
99
|
-
// Only worth the Git calls when the request did not already pick a package and there is more than one to choose from.
|
|
100
|
-
const dirty = type !== 'QUESTION' && type !== 'DOCUMENTATION' && pkgReason.size === 0 && packageDirs(allChecks).length > 1 ? await dirtyFiles(ws.root) : null;
|
|
101
|
-
for (const f of Object.keys(dirty?.files ?? {})) {
|
|
102
|
-
const d = packageDirs(allChecks).find((x) => f === x || f.startsWith(`${x}/`));
|
|
103
|
-
if (d && !pkgReason.has(d))
|
|
104
|
-
pkgReason.set(d, 'has uncommitted code changes');
|
|
105
|
-
}
|
|
106
272
|
const usable = allChecks.filter((c) => c.available);
|
|
107
273
|
const scoped = pkgReason.size > 0 ? usable.filter((c) => !c.cwd || c.cwd === '.' || pkgReason.has(c.cwd)) : usable;
|
|
108
274
|
const checks = type === 'QUESTION' || type === 'DOCUMENTATION' ? [] : scoped;
|
|
@@ -116,17 +282,19 @@ export async function getTaskContext(o, opts) {
|
|
|
116
282
|
safety.push({ package: c.cwd ?? '.', check: c.id, command: c.command, safety: s.safety, reasons: s.reasons });
|
|
117
283
|
}
|
|
118
284
|
SAFETY_RULES.forEach((_, i) => included.push({ kind: 'rule', id: `safety-${i + 1}`, reason: 'safety rule, included in every context' }));
|
|
119
|
-
// Decisions: titles and file references
|
|
285
|
+
// Decisions: titles, context, decision, consequences, and file references
|
|
120
286
|
const decs = (await listDecisions(ws)).decisions;
|
|
121
287
|
const wantAll = type === 'ARCHITECTURE' || type === 'SECURITY';
|
|
122
288
|
const decisions = [];
|
|
123
289
|
for (const d of decs) {
|
|
124
|
-
const
|
|
125
|
-
|
|
290
|
+
const fullText = `${d.title} ${d.decision} ${d.context} ${d.consequences} ${d.file}`;
|
|
291
|
+
const m = matchesQuery(fullText, expandedTerms);
|
|
292
|
+
const nonWeak = m.filter(w => !WEAK_TERMS.has(w));
|
|
293
|
+
if (d.status === 'superseded' && nonWeak.length === 0)
|
|
126
294
|
continue;
|
|
127
|
-
if (
|
|
295
|
+
if (nonWeak.length > 0 || wantAll) {
|
|
128
296
|
decisions.push({ number: d.number, title: d.title, status: d.status, file: d.file });
|
|
129
|
-
included.push({ kind: 'decision', id: String(d.number).padStart(4, '0'), reason:
|
|
297
|
+
included.push({ kind: 'decision', id: String(d.number).padStart(4, '0'), reason: nonWeak.length > 0 ? `matches "${nonWeak[0]}"` : `${type} tasks review all decisions` });
|
|
130
298
|
}
|
|
131
299
|
}
|
|
132
300
|
// Documents: referenced, never embedded
|
|
@@ -136,7 +304,7 @@ export async function getTaskContext(o, opts) {
|
|
|
136
304
|
for (const name of (await fs.readdir(ws.contextDir)).filter((n) => n.endsWith('.md')).sort()) {
|
|
137
305
|
docCount++;
|
|
138
306
|
const byType = (DOCS_BY_TYPE[type] ?? []).includes(name);
|
|
139
|
-
const m =
|
|
307
|
+
const m = matchesQuery(name, expandedTerms);
|
|
140
308
|
if (byType || m.length > 0) {
|
|
141
309
|
documents.push({ name, bytes: (await fs.stat(path.join(ws.contextDir, name))).size });
|
|
142
310
|
included.push({ kind: 'document', id: name, reason: byType ? `reference for ${type}` : `request names "${m[0]}"` });
|
|
@@ -145,7 +313,7 @@ export async function getTaskContext(o, opts) {
|
|
|
145
313
|
}
|
|
146
314
|
return {
|
|
147
315
|
project: { name: project.name, id: project.projectId },
|
|
148
|
-
request
|
|
316
|
+
request,
|
|
149
317
|
type,
|
|
150
318
|
confidence: cls.confidence,
|
|
151
319
|
readOnly: cls.readOnly,
|
|
@@ -161,4 +329,216 @@ export async function getTaskContext(o, opts) {
|
|
|
161
329
|
counts: { facts: [facts.length, all.length], checks: [checks.length, usable.length], decisions: [decisions.length, decs.length], documents: [documents.length, docCount] },
|
|
162
330
|
};
|
|
163
331
|
}
|
|
332
|
+
export async function getTaskContext(o, opts) {
|
|
333
|
+
const base = await buildBaseTaskContext(o, opts.request);
|
|
334
|
+
if (!opts.role)
|
|
335
|
+
return base;
|
|
336
|
+
return getAgentContextSlice(o, { request: opts.request, role: opts.role, base });
|
|
337
|
+
}
|
|
338
|
+
export async function getAgentContextSlice(o, opts) {
|
|
339
|
+
const ws = await requireWorkspace(o.cwd);
|
|
340
|
+
const base = opts.base ?? (await buildBaseTaskContext(o, opts.request));
|
|
341
|
+
const role = opts.role;
|
|
342
|
+
const kw = words(opts.request);
|
|
343
|
+
const expandedTerms = expandQueryTerms(kw);
|
|
344
|
+
const hit = (text) => matchesQuery(text, expandedTerms);
|
|
345
|
+
let slicedFacts = [...base.facts];
|
|
346
|
+
let slicedChecks = [...base.checks];
|
|
347
|
+
let slicedDecisions = [...base.decisions];
|
|
348
|
+
let slicedDocuments = [...base.documents];
|
|
349
|
+
let roleGuidance = [];
|
|
350
|
+
const roleFindings = {};
|
|
351
|
+
if (role === 'explorer') {
|
|
352
|
+
// Explorer: Prioritize entry points, symbols, features, graph connections.
|
|
353
|
+
// Avoid unrelated test commands, entire file contents, complete repo dumps.
|
|
354
|
+
slicedChecks = [];
|
|
355
|
+
slicedFacts = base.facts.filter((f) => {
|
|
356
|
+
if (/^repo\.(?:entry-points|packages|monorepo)$/.test(f.key))
|
|
357
|
+
return true;
|
|
358
|
+
if (STACK_KEY.test(f.key) && !/^convention\.test/i.test(f.key))
|
|
359
|
+
return true;
|
|
360
|
+
const m = hit(`${f.key} ${f.value}`);
|
|
361
|
+
return m.length > 0 && !/^convention\.test/i.test(f.key);
|
|
362
|
+
});
|
|
363
|
+
slicedDecisions = base.decisions.slice(0, 3);
|
|
364
|
+
slicedDocuments = base.documents.filter((d) => ['architecture.md', 'integrations.md'].includes(d.name));
|
|
365
|
+
// Entry points from facts or packages
|
|
366
|
+
const epFact = base.facts.find((f) => f.key === 'repo.entry-points');
|
|
367
|
+
const entryPoints = [];
|
|
368
|
+
if (epFact && epFact.value) {
|
|
369
|
+
for (const p of epFact.value.split(',').map((x) => x.trim()).filter(Boolean)) {
|
|
370
|
+
if (!entryPoints.includes(p))
|
|
371
|
+
entryPoints.push(p);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (entryPoints.length === 0 && base.packages.length > 0) {
|
|
375
|
+
entryPoints.push(...base.packages);
|
|
376
|
+
}
|
|
377
|
+
if (entryPoints.length > 0)
|
|
378
|
+
roleFindings.entryPoints = entryPoints.slice(0, 10);
|
|
379
|
+
// Feature knowledge map references
|
|
380
|
+
try {
|
|
381
|
+
const featFile = await loadFeatures(ws);
|
|
382
|
+
const matchedFeats = featFile.features.filter((f) => {
|
|
383
|
+
const text = `${f.id} ${f.name} ${f.description ?? ''} ${(f.tags ?? []).join(' ')}`;
|
|
384
|
+
return hit(text).length > 0;
|
|
385
|
+
});
|
|
386
|
+
if (matchedFeats.length > 0) {
|
|
387
|
+
roleFindings.features = matchedFeats.slice(0, 8).map((f) => ({
|
|
388
|
+
id: f.id,
|
|
389
|
+
name: f.name,
|
|
390
|
+
status: f.status,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Graceful fallback if features file unreadable
|
|
396
|
+
}
|
|
397
|
+
// Graphify graph lookup
|
|
398
|
+
try {
|
|
399
|
+
const graph = await loadGraphData(ws.root);
|
|
400
|
+
if (graph.exists && graph.data && graph.data.nodes) {
|
|
401
|
+
const matchedNodes = [];
|
|
402
|
+
for (const w of expandedTerms) {
|
|
403
|
+
const n = findMatchingStartNode(graph.data.nodes, w);
|
|
404
|
+
if (n && !matchedNodes.some((x) => x.id === n.id)) {
|
|
405
|
+
matchedNodes.push({
|
|
406
|
+
id: n.id,
|
|
407
|
+
status: typeof n.status === 'string' ? n.status : 'symbol',
|
|
408
|
+
path: n.path || n.file || null,
|
|
409
|
+
symbol: typeof n.name === 'string' ? n.name : null,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (matchedNodes.length >= 6)
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
if (matchedNodes.length > 0)
|
|
416
|
+
roleFindings.graphNodes = matchedNodes;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// Graceful fallback if graph lookup fails
|
|
421
|
+
}
|
|
422
|
+
roleGuidance = [
|
|
423
|
+
'Investigate referenced entry points and feature components before reading full files.',
|
|
424
|
+
'Trace deep call dependencies with "devworkspace graph trace <symbol>".',
|
|
425
|
+
'Conclude exploration with a concise handoff: identified files, uncertainty, and next step.',
|
|
426
|
+
];
|
|
427
|
+
}
|
|
428
|
+
else if (role === 'tester') {
|
|
429
|
+
// Tester: Prioritize test files, test framework conventions, verification contracts, zero-test safeguards.
|
|
430
|
+
slicedFacts = base.facts.filter((f) => {
|
|
431
|
+
if (/^convention\.test/i.test(f.key) || ISSUE_KEY.test(f.key))
|
|
432
|
+
return true;
|
|
433
|
+
if (STACK_KEY.test(f.key) && /test/i.test(f.key + f.value))
|
|
434
|
+
return true;
|
|
435
|
+
const m = hit(`${f.key} ${f.value}`.toLowerCase());
|
|
436
|
+
return m.length > 0;
|
|
437
|
+
});
|
|
438
|
+
slicedChecks = base.checks;
|
|
439
|
+
slicedDocuments = base.documents.filter((d) => d.name === 'testing.md');
|
|
440
|
+
slicedDecisions = base.decisions.filter((d) => /test|verify|quality/i.test(d.title));
|
|
441
|
+
roleFindings.zeroTestSafeguards = [
|
|
442
|
+
'Zero-test pass detection active across Vitest, Jest, Pytest, Mocha, Go, and Cargo.',
|
|
443
|
+
'Commands exiting code 0 with 0 tests executed are classified as unverified false passes.',
|
|
444
|
+
'Record baseline before modifications: devworkspace task tests <id> --phase baseline --passed <n> --failed <n>.',
|
|
445
|
+
"Execute verification via 'devworkspace task run <id> <check>'.",
|
|
446
|
+
];
|
|
447
|
+
roleGuidance = [
|
|
448
|
+
'Verify required contract commands for affected packages before claiming completion.',
|
|
449
|
+
'Write focused regression tests covering defect reproduction or feature acceptance criteria.',
|
|
450
|
+
'Never run destructive commands or test suites touching unmanaged databases without "testenv declare".',
|
|
451
|
+
];
|
|
452
|
+
}
|
|
453
|
+
else if (role === 'reviewer') {
|
|
454
|
+
// Reviewer: Prioritize git diff/changed files, permanent safety rules, ADRs, potential regressions.
|
|
455
|
+
slicedFacts = base.facts.filter((f) => {
|
|
456
|
+
if (SECURITY_WORDS.test(`${f.key} ${f.value}`))
|
|
457
|
+
return true;
|
|
458
|
+
if (/^convention\./i.test(f.key))
|
|
459
|
+
return true;
|
|
460
|
+
const m = hit(`${f.key} ${f.value}`.toLowerCase());
|
|
461
|
+
return m.length > 0;
|
|
462
|
+
});
|
|
463
|
+
// Keep review-relevant checks (lint, typecheck, review, and test suites)
|
|
464
|
+
if (base.checks.length > 0) {
|
|
465
|
+
slicedChecks = base.checks.filter((c) => /lint|typecheck|review|test/i.test(c.id));
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
try {
|
|
469
|
+
if (await pathExists(ws.commands)) {
|
|
470
|
+
const contract = (await readVersioned(ws.commands, 'commands', SCHEMA_VERSION, validateCommands)).data.checks;
|
|
471
|
+
const usable = contract.filter((c) => c.available && /lint|typecheck|review|test/i.test(c.id));
|
|
472
|
+
slicedChecks = base.packages.length > 0
|
|
473
|
+
? usable.filter((c) => !c.cwd || c.cwd === '.' || base.packages.includes(c.cwd))
|
|
474
|
+
: usable;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
slicedChecks = [];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
slicedDecisions = base.decisions;
|
|
482
|
+
slicedDocuments = base.documents.filter((d) => ['conventions.md', 'architecture.md'].includes(d.name));
|
|
483
|
+
try {
|
|
484
|
+
const dirty = await dirtyFiles(ws.root);
|
|
485
|
+
const dirtyList = dirty ? Object.keys(dirty.files) : [];
|
|
486
|
+
if (dirtyList.length > 0) {
|
|
487
|
+
const preview = dirtyList.slice(0, 5).join(', ') + (dirtyList.length > 5 ? ` (+${dirtyList.length - 5} more)` : '');
|
|
488
|
+
roleFindings.diffNotice = `${dirtyList.length} uncommitted file(s) detected: ${preview}. Inspect exact diff with "git diff".`;
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
roleFindings.diffNotice = 'No uncommitted changes detected in working tree. When reviewing a branch or PR, the caller must supply the target diff or commit range.';
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
roleFindings.diffNotice = 'Could not inspect git working tree status. The caller must supply the diff or commit range.';
|
|
496
|
+
}
|
|
497
|
+
roleGuidance = [
|
|
498
|
+
'Audit diff against permanent SAFETY_RULES (no secrets in code/logs, no path traversals, untrusted commands).',
|
|
499
|
+
'Verify changes strictly comply with recorded decisions (ADRs) and feature contracts.',
|
|
500
|
+
'Ensure required verification checks have executed results before approving the completion gate.',
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
const factKeys = new Set(slicedFacts.map((f) => f.key));
|
|
504
|
+
const checkIds = new Set(slicedChecks.map((c) => `${c.cwd ?? '.'}:${c.id}`));
|
|
505
|
+
const decIds = new Set(slicedDecisions.map((d) => String(d.number).padStart(4, '0')));
|
|
506
|
+
const docNames = new Set(slicedDocuments.map((d) => d.name));
|
|
507
|
+
const included = base.included.filter((item) => {
|
|
508
|
+
if (item.kind === 'rule')
|
|
509
|
+
return true;
|
|
510
|
+
if (item.kind === 'fact')
|
|
511
|
+
return factKeys.has(item.id);
|
|
512
|
+
if (item.kind === 'check')
|
|
513
|
+
return checkIds.has(item.id);
|
|
514
|
+
if (item.kind === 'decision')
|
|
515
|
+
return decIds.has(item.id);
|
|
516
|
+
if (item.kind === 'document')
|
|
517
|
+
return docNames.has(item.id);
|
|
518
|
+
return false;
|
|
519
|
+
});
|
|
520
|
+
included.push({
|
|
521
|
+
kind: 'rule',
|
|
522
|
+
id: `role:${role}`,
|
|
523
|
+
reason: `context tailored for ${role} role (Phase 1 role-based context slicing)`,
|
|
524
|
+
});
|
|
525
|
+
return {
|
|
526
|
+
...base,
|
|
527
|
+
role,
|
|
528
|
+
facts: slicedFacts,
|
|
529
|
+
checks: slicedChecks,
|
|
530
|
+
decisions: slicedDecisions,
|
|
531
|
+
documents: slicedDocuments,
|
|
532
|
+
rules: SAFETY_RULES,
|
|
533
|
+
included,
|
|
534
|
+
counts: {
|
|
535
|
+
facts: [slicedFacts.length, base.counts.facts[1]],
|
|
536
|
+
checks: [slicedChecks.length, base.counts.checks[1]],
|
|
537
|
+
decisions: [slicedDecisions.length, base.counts.decisions[1]],
|
|
538
|
+
documents: [slicedDocuments.length, base.counts.documents[1]],
|
|
539
|
+
},
|
|
540
|
+
roleGuidance,
|
|
541
|
+
roleFindings,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
164
544
|
//# sourceMappingURL=taskContext.js.map
|