memorix 1.1.13 → 1.2.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -2
  3. package/README.zh-CN.md +3 -2
  4. package/dist/cli/index.js +36313 -31189
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +5348 -674
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3661 -293
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +19 -0
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +5346 -672
  15. package/dist/sdk.js.map +1 -1
  16. package/docs/1.2.0-CLAIM-LEDGER.md +72 -0
  17. package/docs/1.2.0-CODE-STATE.md +61 -0
  18. package/docs/1.2.0-DEVELOPMENT-CHARTER.md +255 -0
  19. package/docs/1.2.0-DYNAMIC-LIFECYCLE.md +71 -0
  20. package/docs/1.2.0-EVALUATION-HARNESS.md +51 -0
  21. package/docs/1.2.0-IMPLEMENTATION-PLAN.md +554 -0
  22. package/docs/1.2.0-KNOWLEDGE-WORKFLOW-RESEARCH.md +205 -0
  23. package/docs/1.2.0-KNOWLEDGE-WORKSPACE.md +68 -0
  24. package/docs/1.2.0-PRODUCT-STORY.md +234 -0
  25. package/docs/1.2.0-PROVIDER-QUALITY.md +189 -0
  26. package/docs/1.2.0-WORKFLOW-INHERITANCE.md +101 -0
  27. package/docs/1.2.0-WORKSET-RETRIEVAL.md +80 -0
  28. package/docs/AGENT_OPERATOR_PLAYBOOK.md +6 -3
  29. package/docs/API_REFERENCE.md +25 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/README.md +17 -2
  32. package/docs/dev-log/progress.txt +120 -40
  33. package/llms-full.txt +16 -2
  34. package/llms.txt +9 -4
  35. package/package.json +3 -2
  36. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  37. package/src/cli/capability-map.ts +1 -0
  38. package/src/cli/commands/codegraph.ts +112 -9
  39. package/src/cli/commands/context.ts +2 -0
  40. package/src/cli/commands/doctor.ts +73 -4
  41. package/src/cli/commands/knowledge.ts +282 -0
  42. package/src/cli/commands/serve-http.ts +12 -1
  43. package/src/cli/index.ts +3 -1
  44. package/src/cli/tui/App.tsx +1 -1
  45. package/src/cli/tui/Panels.tsx +8 -8
  46. package/src/cli/tui/theme.ts +1 -1
  47. package/src/cli/tui/views/GraphView.tsx +8 -7
  48. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  49. package/src/codegraph/auto-context.ts +171 -9
  50. package/src/codegraph/code-state.ts +95 -0
  51. package/src/codegraph/context-pack.ts +82 -1
  52. package/src/codegraph/external-provider.ts +581 -0
  53. package/src/codegraph/lite-provider.ts +64 -19
  54. package/src/codegraph/project-context.ts +9 -1
  55. package/src/codegraph/store.ts +154 -6
  56. package/src/codegraph/types.ts +117 -0
  57. package/src/config/resolved-config.ts +28 -0
  58. package/src/config/toml-loader.ts +3 -0
  59. package/src/config/yaml-loader.ts +6 -0
  60. package/src/dashboard/server.ts +15 -1
  61. package/src/evaluation/workset-evaluation.ts +120 -0
  62. package/src/hooks/handler.ts +48 -6
  63. package/src/knowledge/claim-store.ts +267 -0
  64. package/src/knowledge/claims.ts +537 -0
  65. package/src/knowledge/markdown.ts +129 -0
  66. package/src/knowledge/types.ts +157 -0
  67. package/src/knowledge/wiki.ts +524 -0
  68. package/src/knowledge/workflow-store.ts +168 -0
  69. package/src/knowledge/workflow-types.ts +95 -0
  70. package/src/knowledge/workflows.ts +743 -0
  71. package/src/knowledge/workset.ts +515 -0
  72. package/src/knowledge/workspace-store.ts +220 -0
  73. package/src/knowledge/workspace-types.ts +106 -0
  74. package/src/knowledge/workspace.ts +220 -0
  75. package/src/memory/observations.ts +19 -0
  76. package/src/runtime/control-plane-maintenance.ts +5 -0
  77. package/src/runtime/isolated-maintenance.ts +5 -0
  78. package/src/runtime/lifecycle-status.ts +102 -0
  79. package/src/runtime/lifecycle.ts +107 -0
  80. package/src/runtime/maintenance-jobs.ts +5 -0
  81. package/src/runtime/project-maintenance.ts +190 -0
  82. package/src/server/tool-profile.ts +3 -2
  83. package/src/server.ts +354 -14
  84. package/src/store/file-lock.ts +24 -4
  85. package/src/store/sqlite-db.ts +307 -0
  86. package/src/wiki/generator.ts +4 -2
  87. package/src/wiki/knowledge-graph.ts +7 -4
  88. package/src/wiki/types.ts +16 -4
@@ -0,0 +1,743 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import type { AgentTarget } from '../types.js';
6
+ import { atomicWriteFile, withFileLock } from '../store/file-lock.js';
7
+ import { WorkflowSyncer } from '../workspace/workflow-sync.js';
8
+ import { getKnowledgeWorkspacePaths, resolveKnowledgeWorkspaceFile } from './workspace.js';
9
+ import { WorkflowStore } from './workflow-store.js';
10
+ import type {
11
+ WorkflowAdapterPreview,
12
+ WorkflowAdapterTarget,
13
+ WorkflowPhase,
14
+ WorkflowRun,
15
+ WorkflowRunInput,
16
+ WorkflowSelection,
17
+ WorkflowSpec,
18
+ WorkflowStatus,
19
+ } from './workflow-types.js';
20
+ import type { KnowledgeWorkspace } from './workspace-types.js';
21
+
22
+ const KNOWN_AGENTS: AgentTarget[] = [
23
+ 'windsurf',
24
+ 'cursor',
25
+ 'claude-code',
26
+ 'codex',
27
+ 'copilot',
28
+ 'antigravity',
29
+ 'gemini-cli',
30
+ 'openclaw',
31
+ 'hermes',
32
+ 'omp',
33
+ 'kiro',
34
+ 'opencode',
35
+ 'trae',
36
+ ];
37
+
38
+ export const WORKFLOW_ADAPTER_TARGETS: WorkflowAdapterTarget[] = [
39
+ 'codex',
40
+ 'claude-code',
41
+ 'cursor',
42
+ 'windsurf',
43
+ ];
44
+
45
+ const STATUS_VALUES: WorkflowStatus[] = ['draft', 'active', 'archived'];
46
+
47
+ function hash(value: string): string {
48
+ return createHash('sha256').update(value).digest('hex');
49
+ }
50
+
51
+ function now(): string {
52
+ return new Date().toISOString();
53
+ }
54
+
55
+ function slug(value: string): string {
56
+ const normalized = value
57
+ .trim()
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
60
+ .replace(/^-+|-+$/g, '')
61
+ .slice(0, 72);
62
+ return normalized || 'workflow';
63
+ }
64
+
65
+ function titleFromName(name: string): string {
66
+ return name
67
+ .replace(/[-_]+/g, ' ')
68
+ .split(' ')
69
+ .filter(Boolean)
70
+ .map(part => part.slice(0, 1).toUpperCase() + part.slice(1))
71
+ .join(' ') || 'Imported Workflow';
72
+ }
73
+
74
+ function requiredText(data: Record<string, unknown>, key: string): string {
75
+ const value = data[key];
76
+ if (typeof value !== 'string' || !value.trim()) {
77
+ throw new Error('workflow frontmatter requires ' + key);
78
+ }
79
+ return value.trim();
80
+ }
81
+
82
+ function optionalText(data: Record<string, unknown>, key: string): string | undefined {
83
+ const value = data[key];
84
+ if (value === undefined || value === null || value === '') return undefined;
85
+ if (typeof value !== 'string') throw new Error('workflow frontmatter field ' + key + ' must be text');
86
+ return value.trim() || undefined;
87
+ }
88
+
89
+ function optionalStringArray(data: Record<string, unknown>, key: string): string[] {
90
+ const value = data[key];
91
+ if (value === undefined || value === null) return [];
92
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
93
+ throw new Error('workflow frontmatter field ' + key + ' must be a string array');
94
+ }
95
+ return [...new Set(value.map(item => item.trim()).filter(Boolean))];
96
+ }
97
+
98
+ function optionalNumber(data: Record<string, unknown>, key: string, fallback: number): number {
99
+ const value = data[key];
100
+ if (value === undefined || value === null || value === '') return fallback;
101
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
102
+ throw new Error('workflow frontmatter field ' + key + ' must be a positive integer');
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function optionalStatus(data: Record<string, unknown>): WorkflowStatus {
108
+ const value = data.status;
109
+ if (value === undefined || value === null || value === '') return 'draft';
110
+ if (typeof value !== 'string' || !STATUS_VALUES.includes(value as WorkflowStatus)) {
111
+ throw new Error('workflow frontmatter field status is invalid');
112
+ }
113
+ return value as WorkflowStatus;
114
+ }
115
+
116
+ function normalizeAgents(values: string[]): AgentTarget[] {
117
+ const unknown = values.filter(value => !KNOWN_AGENTS.includes(value as AgentTarget));
118
+ if (unknown.length > 0) {
119
+ throw new Error('workflow frontmatter has unsupported agent: ' + unknown[0]);
120
+ }
121
+ return values as AgentTarget[];
122
+ }
123
+
124
+ function phaseFromValue(value: unknown, index: number): WorkflowPhase {
125
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
126
+ throw new Error('workflow frontmatter phases must contain objects');
127
+ }
128
+ const data = value as Record<string, unknown>;
129
+ const title = requiredText(data, 'title');
130
+ const phaseId = optionalText(data, 'id') ?? slug(title) + '-' + (index + 1);
131
+ return {
132
+ id: phaseId,
133
+ title,
134
+ instructions: optionalText(data, 'instructions') ?? '',
135
+ branches: optionalStringArray(data, 'branches'),
136
+ expectedOutputs: optionalStringArray(data, 'expectedOutputs'),
137
+ verificationGates: optionalStringArray(data, 'verificationGates'),
138
+ };
139
+ }
140
+
141
+ function phasesFromBody(body: string): WorkflowPhase[] {
142
+ const headers = [...body.matchAll(/^##\s+(.+?)\s*$/gm)];
143
+ if (headers.length === 0) {
144
+ const instructions = body.trim();
145
+ return instructions
146
+ ? [{
147
+ id: 'execute',
148
+ title: 'Execute',
149
+ instructions,
150
+ branches: [],
151
+ expectedOutputs: [],
152
+ verificationGates: [],
153
+ }]
154
+ : [];
155
+ }
156
+ return headers.map((match, index) => {
157
+ const title = match[1].trim();
158
+ const start = (match.index ?? 0) + match[0].length;
159
+ const end = index + 1 < headers.length
160
+ ? (headers[index + 1].index ?? body.length)
161
+ : body.length;
162
+ return {
163
+ id: slug(title) + '-' + (index + 1),
164
+ title,
165
+ instructions: body.slice(start, end).trim(),
166
+ branches: [],
167
+ expectedOutputs: [],
168
+ verificationGates: [],
169
+ };
170
+ });
171
+ }
172
+
173
+ function normalizeSourcePath(sourcePath: string): string {
174
+ const normalized = sourcePath.replace(/\\/g, '/');
175
+ if (
176
+ !normalized.startsWith('workflows/')
177
+ || !normalized.toLowerCase().endsWith('.md')
178
+ || normalized.includes('../')
179
+ || path.posix.normalize(normalized) !== normalized
180
+ ) {
181
+ throw new Error('workflow source path must be a safe workflows/*.md path');
182
+ }
183
+ return normalized;
184
+ }
185
+
186
+ function sourceHashFor(workflow: Omit<WorkflowSpec, 'sourceHash' | 'contentHash'> | WorkflowSpec): string {
187
+ return hash(JSON.stringify({
188
+ id: workflow.id,
189
+ title: workflow.title,
190
+ description: workflow.description,
191
+ status: workflow.status,
192
+ version: workflow.version,
193
+ taskLenses: workflow.taskLenses,
194
+ triggers: workflow.triggers,
195
+ assumptions: workflow.assumptions,
196
+ requiredContext: workflow.requiredContext,
197
+ guardrails: workflow.guardrails,
198
+ allowedTools: workflow.allowedTools,
199
+ phases: workflow.phases,
200
+ verificationGates: workflow.verificationGates,
201
+ claimIds: workflow.claimIds,
202
+ evidenceRefs: workflow.evidenceRefs,
203
+ codeRefs: workflow.codeRefs,
204
+ compatibleAgents: workflow.compatibleAgents,
205
+ body: workflow.body,
206
+ sourcePath: workflow.sourcePath,
207
+ importedFrom: workflow.importedFrom ?? null,
208
+ }));
209
+ }
210
+
211
+ function workflowFrontmatter(workflow: WorkflowSpec, sourceHash: string): Record<string, unknown> {
212
+ return {
213
+ id: workflow.id,
214
+ title: workflow.title,
215
+ description: workflow.description,
216
+ status: workflow.status,
217
+ version: workflow.version,
218
+ taskLenses: workflow.taskLenses,
219
+ triggers: workflow.triggers,
220
+ assumptions: workflow.assumptions,
221
+ requiredContext: workflow.requiredContext,
222
+ guardrails: workflow.guardrails,
223
+ allowedTools: workflow.allowedTools,
224
+ phases: workflow.phases.map(phase => ({
225
+ id: phase.id,
226
+ title: phase.title,
227
+ ...(phase.instructions ? { instructions: phase.instructions } : {}),
228
+ ...(phase.branches.length ? { branches: phase.branches } : {}),
229
+ ...(phase.expectedOutputs.length ? { expectedOutputs: phase.expectedOutputs } : {}),
230
+ ...(phase.verificationGates.length ? { verificationGates: phase.verificationGates } : {}),
231
+ })),
232
+ verificationGates: workflow.verificationGates,
233
+ claimIds: workflow.claimIds,
234
+ evidenceRefs: workflow.evidenceRefs,
235
+ codeRefs: workflow.codeRefs,
236
+ compatibleAgents: workflow.compatibleAgents,
237
+ sourceHash,
238
+ createdAt: workflow.createdAt,
239
+ updatedAt: workflow.updatedAt,
240
+ ...(workflow.importedFrom ? { importedFrom: workflow.importedFrom } : {}),
241
+ };
242
+ }
243
+
244
+ export function renderWorkflowMarkdown(workflow: WorkflowSpec): string {
245
+ const sourceHash = sourceHashFor(workflow);
246
+ return matter.stringify(workflow.body.trimEnd() + '\n', workflowFrontmatter(workflow, sourceHash));
247
+ }
248
+
249
+ function parsedWorkflow(data: Record<string, unknown>, body: string, input: {
250
+ workspaceId: string;
251
+ sourcePath: string;
252
+ contentHash: string;
253
+ }): WorkflowSpec {
254
+ const phasesValue = data.phases;
255
+ const parsedPhases = phasesValue === undefined || phasesValue === null
256
+ ? phasesFromBody(body)
257
+ : Array.isArray(phasesValue)
258
+ ? phasesValue.map(phaseFromValue)
259
+ : (() => { throw new Error('workflow frontmatter field phases must be an array'); })();
260
+ const candidate: WorkflowSpec = {
261
+ id: requiredText(data, 'id'),
262
+ workspaceId: input.workspaceId,
263
+ title: requiredText(data, 'title'),
264
+ description: optionalText(data, 'description') ?? '',
265
+ status: optionalStatus(data),
266
+ version: optionalNumber(data, 'version', 1),
267
+ taskLenses: optionalStringArray(data, 'taskLenses'),
268
+ triggers: optionalStringArray(data, 'triggers'),
269
+ assumptions: optionalStringArray(data, 'assumptions'),
270
+ requiredContext: optionalStringArray(data, 'requiredContext'),
271
+ guardrails: optionalStringArray(data, 'guardrails'),
272
+ allowedTools: optionalStringArray(data, 'allowedTools'),
273
+ phases: parsedPhases,
274
+ verificationGates: optionalStringArray(data, 'verificationGates'),
275
+ claimIds: optionalStringArray(data, 'claimIds'),
276
+ evidenceRefs: optionalStringArray(data, 'evidenceRefs'),
277
+ codeRefs: optionalStringArray(data, 'codeRefs'),
278
+ compatibleAgents: normalizeAgents(optionalStringArray(data, 'compatibleAgents').length
279
+ ? optionalStringArray(data, 'compatibleAgents')
280
+ : optionalStringArray(data, 'allowedAgents')),
281
+ body: body.trim(),
282
+ sourcePath: normalizeSourcePath(input.sourcePath),
283
+ sourceHash: '',
284
+ contentHash: input.contentHash,
285
+ createdAt: optionalText(data, 'createdAt') ?? now(),
286
+ updatedAt: optionalText(data, 'updatedAt') ?? now(),
287
+ ...(optionalText(data, 'importedFrom') ? { importedFrom: optionalText(data, 'importedFrom') } : {}),
288
+ };
289
+ if (candidate.phases.length === 0) {
290
+ throw new Error('workflow must contain at least one phase or a non-empty body');
291
+ }
292
+ return { ...candidate, sourceHash: sourceHashFor(candidate) };
293
+ }
294
+
295
+ export function parseWorkflowMarkdown(raw: string, input: {
296
+ workspaceId: string;
297
+ sourcePath: string;
298
+ }): WorkflowSpec {
299
+ let parsed: matter.GrayMatterFile<string>;
300
+ try {
301
+ parsed = matter(raw);
302
+ } catch (error) {
303
+ throw new Error('Malformed workflow Markdown: ' + (error instanceof Error ? error.message : String(error)));
304
+ }
305
+ return parsedWorkflow(parsed.data as Record<string, unknown>, parsed.content, {
306
+ workspaceId: input.workspaceId,
307
+ sourcePath: input.sourcePath,
308
+ contentHash: hash(raw),
309
+ });
310
+ }
311
+
312
+ function workflowStoreDataDir(workspace: KnowledgeWorkspace): string {
313
+ if (!workspace.dataDir) throw new Error('Knowledge workspace is missing its local data directory');
314
+ return workspace.dataDir;
315
+ }
316
+
317
+ async function storeFor(workspace: KnowledgeWorkspace): Promise<WorkflowStore> {
318
+ const store = new WorkflowStore();
319
+ await store.init(workflowStoreDataDir(workspace));
320
+ return store;
321
+ }
322
+
323
+ function materializeWorkflow(workflow: WorkflowSpec): WorkflowSpec {
324
+ const updatedAt = workflow.updatedAt || now();
325
+ const withDates = {
326
+ ...workflow,
327
+ createdAt: workflow.createdAt || updatedAt,
328
+ updatedAt,
329
+ };
330
+ const sourceHash = sourceHashFor(withDates);
331
+ const content = renderWorkflowMarkdown({ ...withDates, sourceHash, contentHash: '' });
332
+ return {
333
+ ...withDates,
334
+ sourceHash,
335
+ contentHash: hash(content),
336
+ };
337
+ }
338
+
339
+ export async function writeCanonicalWorkflow(input: {
340
+ workspace: KnowledgeWorkspace;
341
+ workflow: WorkflowSpec;
342
+ }): Promise<WorkflowSpec> {
343
+ const paths = getKnowledgeWorkspacePaths(input.workspace);
344
+ const workflow = materializeWorkflow({
345
+ ...input.workflow,
346
+ workspaceId: input.workspace.id,
347
+ sourcePath: normalizeSourcePath(input.workflow.sourcePath),
348
+ });
349
+ const filePath = resolveKnowledgeWorkspaceFile(input.workspace, workflow.sourcePath);
350
+ const content = renderWorkflowMarkdown(workflow);
351
+ await withFileLock(paths.root, async () => {
352
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
353
+ await atomicWriteFile(filePath, content);
354
+ });
355
+ const store = await storeFor(input.workspace);
356
+ return store.upsertWorkflow({ ...workflow, contentHash: hash(content) });
357
+ }
358
+
359
+ async function markdownFiles(directory: string): Promise<string[]> {
360
+ let entries: Array<import('node:fs').Dirent>;
361
+ try {
362
+ entries = await fs.readdir(directory, { withFileTypes: true });
363
+ } catch {
364
+ return [];
365
+ }
366
+ const files: string[] = [];
367
+ for (const entry of entries) {
368
+ const absolutePath = path.join(directory, entry.name);
369
+ if (entry.isDirectory()) {
370
+ files.push(...await markdownFiles(absolutePath));
371
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
372
+ files.push(absolutePath);
373
+ }
374
+ }
375
+ return files;
376
+ }
377
+
378
+ /**
379
+ * Make manually authored canonical files visible to selection without changing
380
+ * them. Invalid files are returned as diagnostics instead of being treated as
381
+ * active project instructions.
382
+ */
383
+ export async function syncCanonicalWorkflows(workspace: KnowledgeWorkspace): Promise<{
384
+ workflows: WorkflowSpec[];
385
+ errors: Array<{ sourcePath: string; message: string }>;
386
+ }> {
387
+ const paths = getKnowledgeWorkspacePaths(workspace);
388
+ const store = await storeFor(workspace);
389
+ const workflows: WorkflowSpec[] = [];
390
+ const errors: Array<{ sourcePath: string; message: string }> = [];
391
+ for (const absolutePath of await markdownFiles(paths.workflows)) {
392
+ const sourcePath = path.relative(paths.root, absolutePath).split(path.sep).join('/');
393
+ try {
394
+ const raw = await fs.readFile(absolutePath, 'utf8');
395
+ const workflow = parseWorkflowMarkdown(raw, { workspaceId: workspace.id, sourcePath });
396
+ workflows.push(store.upsertWorkflow(workflow));
397
+ } catch (error) {
398
+ errors.push({
399
+ sourcePath,
400
+ message: error instanceof Error ? error.message : String(error),
401
+ });
402
+ }
403
+ }
404
+ return { workflows, errors };
405
+ }
406
+
407
+ const LENS_TERMS: Record<string, string[]> = {
408
+ release: ['release', 'publish', 'npm', 'version', '发版', '发布'],
409
+ bugfix: ['bug', 'fix', 'error', 'failure', 'issue', 'repair', '修复', '报错', '故障'],
410
+ migration: ['migration', 'migrate', 'upgrade', '迁移', '升级'],
411
+ review: ['review', 'audit', 'pr', 'code review', '审查', '评审'],
412
+ onboarding: ['onboard', 'onboarding', 'understand', 'introduce', '接手', '了解'],
413
+ refactor: ['refactor', 'cleanup', 'restructure', '重构', '整理'],
414
+ test: ['test', 'verify', 'smoke', '测试', '验证'],
415
+ };
416
+
417
+ function inferTaskLenses(value: string): string[] {
418
+ const text = value.toLowerCase();
419
+ return Object.entries(LENS_TERMS)
420
+ .filter(([, terms]) => terms.some(term => text.includes(term)))
421
+ .map(([lens]) => lens);
422
+ }
423
+
424
+ function taskScore(workflow: WorkflowSpec, task: string): { score: number; reasons: string[] } {
425
+ const text = task.toLowerCase();
426
+ let score = 0;
427
+ const reasons: string[] = [];
428
+ for (const lens of workflow.taskLenses) {
429
+ const terms = LENS_TERMS[lens.toLowerCase()] ?? [lens.toLowerCase()];
430
+ if (terms.some(term => text.includes(term))) {
431
+ score += 20;
432
+ reasons.push('matches ' + lens + ' workflow');
433
+ }
434
+ }
435
+ for (const trigger of workflow.triggers) {
436
+ const term = trigger.toLowerCase().trim();
437
+ if (term.length >= 2 && text.includes(term)) {
438
+ score += 8;
439
+ reasons.push('matches trigger "' + trigger + '"');
440
+ }
441
+ }
442
+ return { score, reasons: [...new Set(reasons)] };
443
+ }
444
+
445
+ export function selectWorkflows(input: {
446
+ workflows: WorkflowSpec[];
447
+ task: string;
448
+ projectId?: string;
449
+ store?: WorkflowStore;
450
+ limit?: number;
451
+ }): WorkflowSelection[] {
452
+ const selected = input.workflows
453
+ .filter(workflow => workflow.status === 'active')
454
+ .map(workflow => {
455
+ const match = taskScore(workflow, input.task);
456
+ const cautions = input.store && input.projectId
457
+ ? input.store.recentFailureCautions(input.projectId, workflow.id)
458
+ : [];
459
+ return {
460
+ workflow,
461
+ score: match.score,
462
+ reasons: match.reasons,
463
+ firstPhase: workflow.phases[0],
464
+ cautions,
465
+ };
466
+ })
467
+ .filter(item => item.score > 0)
468
+ .sort((left, right) => right.score - left.score || left.workflow.title.localeCompare(right.workflow.title));
469
+ return selected.slice(0, Math.max(1, Math.min(input.limit ?? 2, 2)));
470
+ }
471
+
472
+ function importedWorkflowSpec(input: {
473
+ workspace: KnowledgeWorkspace;
474
+ sourceName: string;
475
+ sourcePath: string;
476
+ raw: string;
477
+ }): WorkflowSpec {
478
+ const entry = new WorkflowSyncer().parseWindsurfWorkflow(input.sourceName, input.raw);
479
+ const content = entry.content.trim() || '## Execute\n\nFollow the imported workflow.';
480
+ const lenses = inferTaskLenses(entry.name + '\n' + entry.description + '\n' + content);
481
+ const createdAt = now();
482
+ const sourcePath = 'workflows/' + slug(entry.name) + '.md';
483
+ const spec: WorkflowSpec = {
484
+ id: 'workflow:' + hash(input.workspace.id + ':' + sourcePath).slice(0, 24),
485
+ workspaceId: input.workspace.id,
486
+ title: titleFromName(entry.name),
487
+ description: entry.description || 'Imported from ' + input.sourcePath,
488
+ status: 'active',
489
+ version: 1,
490
+ taskLenses: lenses,
491
+ triggers: lenses,
492
+ assumptions: [],
493
+ requiredContext: [],
494
+ guardrails: [],
495
+ allowedTools: [],
496
+ phases: phasesFromBody(content),
497
+ verificationGates: [],
498
+ claimIds: [],
499
+ evidenceRefs: [],
500
+ codeRefs: [],
501
+ compatibleAgents: WORKFLOW_ADAPTER_TARGETS,
502
+ body: content,
503
+ sourcePath,
504
+ sourceHash: '',
505
+ contentHash: '',
506
+ createdAt,
507
+ updatedAt: createdAt,
508
+ importedFrom: input.sourcePath.replace(/\\/g, '/'),
509
+ };
510
+ return materializeWorkflow(spec);
511
+ }
512
+
513
+ /**
514
+ * Import legacy Windsurf workflows into canonical Markdown. The original
515
+ * source is read only; existing canonical workflow files are not overwritten.
516
+ */
517
+ export async function importWindsurfWorkflows(input: {
518
+ workspace: KnowledgeWorkspace;
519
+ projectRoot: string;
520
+ }): Promise<{
521
+ imported: WorkflowSpec[];
522
+ skipped: Array<{ sourcePath: string; reason: string }>;
523
+ }> {
524
+ const sourceDirectory = path.join(path.resolve(input.projectRoot), '.windsurf', 'workflows');
525
+ const paths = getKnowledgeWorkspacePaths(input.workspace);
526
+ const imported: WorkflowSpec[] = [];
527
+ const skipped: Array<{ sourcePath: string; reason: string }> = [];
528
+ let entries: Array<import('node:fs').Dirent>;
529
+ try {
530
+ entries = await fs.readdir(sourceDirectory, { withFileTypes: true });
531
+ } catch {
532
+ return { imported, skipped };
533
+ }
534
+ for (const entry of entries.filter(item => item.isFile() && item.name.toLowerCase().endsWith('.md'))) {
535
+ const originalPath = path.join(sourceDirectory, entry.name);
536
+ const sourcePath = path.relative(path.resolve(input.projectRoot), originalPath).split(path.sep).join('/');
537
+ try {
538
+ const raw = await fs.readFile(originalPath, 'utf8');
539
+ const workflow = importedWorkflowSpec({
540
+ workspace: input.workspace,
541
+ sourceName: entry.name,
542
+ sourcePath,
543
+ raw,
544
+ });
545
+ const targetPath = resolveKnowledgeWorkspaceFile(input.workspace, workflow.sourcePath);
546
+ try {
547
+ await fs.access(targetPath);
548
+ skipped.push({ sourcePath, reason: 'canonical workflow already exists and was preserved' });
549
+ continue;
550
+ } catch {
551
+ // The generated canonical path is available.
552
+ }
553
+ imported.push(await writeCanonicalWorkflow({ workspace: input.workspace, workflow }));
554
+ } catch (error) {
555
+ skipped.push({
556
+ sourcePath,
557
+ reason: error instanceof Error ? error.message : String(error),
558
+ });
559
+ }
560
+ }
561
+ await fs.mkdir(paths.workflows, { recursive: true });
562
+ return { imported, skipped };
563
+ }
564
+
565
+ function isWithin(root: string, candidate: string): boolean {
566
+ const relative = path.relative(root, candidate);
567
+ return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
568
+ }
569
+
570
+ function adapterRelativePath(workflow: WorkflowSpec, agent: WorkflowAdapterTarget): string {
571
+ const name = 'memorix-workflow-' + slug(workflow.id.replace(/^workflow:/, ''));
572
+ if (agent === 'codex') return '.agents/skills/' + name + '/SKILL.md';
573
+ if (agent === 'claude-code') return '.claude/skills/' + name + '/SKILL.md';
574
+ if (agent === 'cursor') return '.cursor/rules/' + name + '.mdc';
575
+ return '.windsurf/workflows/' + name + '.md';
576
+ }
577
+
578
+ function adapterMarker(workflow: WorkflowSpec): string {
579
+ return '<!-- memorix:workflow-adapter id="' + workflow.id + '" -->';
580
+ }
581
+
582
+ function adapterContent(workflow: WorkflowSpec, agent: WorkflowAdapterTarget): string {
583
+ const marker = adapterMarker(workflow);
584
+ const firstPhase = workflow.phases[0];
585
+ const gates = workflow.verificationGates.length
586
+ ? workflow.verificationGates
587
+ : firstPhase.verificationGates;
588
+ if (agent === 'codex' || agent === 'claude-code') {
589
+ return matter.stringify([
590
+ marker,
591
+ '',
592
+ '# ' + workflow.title,
593
+ '',
594
+ workflow.description,
595
+ '',
596
+ '## Start',
597
+ '',
598
+ firstPhase.instructions || firstPhase.title,
599
+ '',
600
+ '## Verification',
601
+ '',
602
+ ...(gates.length ? gates.map(gate => '- ' + gate) : ['- Follow the project verification standards.']),
603
+ '',
604
+ '## Full workflow',
605
+ '',
606
+ workflow.body.trim(),
607
+ '',
608
+ ].join('\n'), {
609
+ name: 'memorix-workflow-' + slug(workflow.id.replace(/^workflow:/, '')),
610
+ description: workflow.description || workflow.title,
611
+ });
612
+ }
613
+ if (agent === 'cursor') {
614
+ return matter.stringify([
615
+ marker,
616
+ '',
617
+ '# ' + workflow.title,
618
+ '',
619
+ workflow.body.trim(),
620
+ '',
621
+ ].join('\n'), {
622
+ description: workflow.description || workflow.title,
623
+ globs: '',
624
+ alwaysApply: false,
625
+ });
626
+ }
627
+ return matter.stringify([
628
+ marker,
629
+ '',
630
+ '# ' + workflow.title,
631
+ '',
632
+ workflow.body.trim(),
633
+ '',
634
+ ].join('\n'), {
635
+ description: workflow.description || workflow.title,
636
+ });
637
+ }
638
+
639
+ function isWorkflowAdapterTarget(agent: AgentTarget): agent is WorkflowAdapterTarget {
640
+ return WORKFLOW_ADAPTER_TARGETS.includes(agent as WorkflowAdapterTarget);
641
+ }
642
+
643
+ export async function previewWorkflowAdapter(input: {
644
+ workflow: WorkflowSpec;
645
+ projectRoot: string;
646
+ agent: AgentTarget;
647
+ }): Promise<WorkflowAdapterPreview> {
648
+ if (!isWorkflowAdapterTarget(input.agent)) {
649
+ return {
650
+ agent: input.agent,
651
+ workflowId: input.workflow.id,
652
+ status: 'unsupported',
653
+ reason: 'This agent has no safe native workflow adapter. Use Memorix Project Context instead.',
654
+ };
655
+ }
656
+ if (
657
+ input.workflow.compatibleAgents.length > 0
658
+ && !input.workflow.compatibleAgents.includes(input.agent)
659
+ ) {
660
+ return {
661
+ agent: input.agent,
662
+ workflowId: input.workflow.id,
663
+ status: 'unsupported',
664
+ reason: 'The canonical workflow does not declare compatibility with this agent.',
665
+ };
666
+ }
667
+ const projectRoot = path.resolve(input.projectRoot);
668
+ const targetPath = path.resolve(projectRoot, adapterRelativePath(input.workflow, input.agent));
669
+ if (!isWithin(projectRoot, targetPath)) {
670
+ throw new Error('Workflow adapter target escapes the project root');
671
+ }
672
+ const content = adapterContent(input.workflow, input.agent);
673
+ let existing: string | undefined;
674
+ try {
675
+ existing = await fs.readFile(targetPath, 'utf8');
676
+ } catch (error) {
677
+ const code = error && typeof error === 'object' && 'code' in error
678
+ ? (error as { code?: string }).code
679
+ : undefined;
680
+ if (code !== 'ENOENT') throw error;
681
+ }
682
+ if (existing === undefined) {
683
+ return { agent: input.agent, workflowId: input.workflow.id, targetPath, content, status: 'create', reason: 'No existing project adapter file.' };
684
+ }
685
+ if (existing === content) {
686
+ return { agent: input.agent, workflowId: input.workflow.id, targetPath, content, status: 'unchanged', reason: 'Existing Memorix adapter already matches the canonical workflow.' };
687
+ }
688
+ if (existing.includes(adapterMarker(input.workflow))) {
689
+ return { agent: input.agent, workflowId: input.workflow.id, targetPath, content, status: 'update', reason: 'Existing Memorix-owned adapter will be updated.' };
690
+ }
691
+ return { agent: input.agent, workflowId: input.workflow.id, targetPath, content, status: 'conflict', reason: 'A user-owned project file already occupies this adapter path.' };
692
+ }
693
+
694
+ export async function applyWorkflowAdapter(input: {
695
+ workflow: WorkflowSpec;
696
+ projectRoot: string;
697
+ agent: AgentTarget;
698
+ }): Promise<WorkflowAdapterPreview> {
699
+ const initial = await previewWorkflowAdapter(input);
700
+ if (initial.status === 'unsupported' || initial.status === 'conflict' || initial.status === 'unchanged') {
701
+ return initial;
702
+ }
703
+ const projectRoot = path.resolve(input.projectRoot);
704
+ return withFileLock(projectRoot, async () => {
705
+ const current = await previewWorkflowAdapter(input);
706
+ if (current.status !== 'create' && current.status !== 'update') return current;
707
+ await fs.mkdir(path.dirname(current.targetPath!), { recursive: true });
708
+ await atomicWriteFile(current.targetPath!, current.content!);
709
+ return current;
710
+ });
711
+ }
712
+
713
+ export async function recordWorkflowRun(input: {
714
+ workspace: KnowledgeWorkspace;
715
+ run: WorkflowRunInput;
716
+ }): Promise<WorkflowRun> {
717
+ const store = await storeFor(input.workspace);
718
+ const workflow = store.getWorkflow(input.run.workflowId);
719
+ if (!workflow || workflow.workspaceId !== input.workspace.id) {
720
+ throw new Error('Workflow was not found for this workspace');
721
+ }
722
+ if (!input.run.task.trim()) throw new Error('Workflow run task is required');
723
+ return store.recordRun(input.run);
724
+ }
725
+
726
+ export async function selectWorkspaceWorkflows(input: {
727
+ workspace: KnowledgeWorkspace;
728
+ task: string;
729
+ limit?: number;
730
+ }): Promise<{ selections: WorkflowSelection[]; errors: Array<{ sourcePath: string; message: string }> }> {
731
+ const synced = await syncCanonicalWorkflows(input.workspace);
732
+ const store = await storeFor(input.workspace);
733
+ return {
734
+ selections: selectWorkflows({
735
+ workflows: synced.workflows,
736
+ task: input.task,
737
+ projectId: input.workspace.projectId,
738
+ store,
739
+ limit: input.limit,
740
+ }),
741
+ errors: synced.errors,
742
+ };
743
+ }