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.
- package/CHANGELOG.md +19 -0
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dist/cli/index.js +36313 -31189
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/static/app.js +50 -30
- package/dist/index.js +5348 -674
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.d.ts +1 -1
- package/dist/maintenance-runner.js +3661 -293
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +19 -0
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +5346 -672
- package/dist/sdk.js.map +1 -1
- package/docs/1.2.0-CLAIM-LEDGER.md +72 -0
- package/docs/1.2.0-CODE-STATE.md +61 -0
- package/docs/1.2.0-DEVELOPMENT-CHARTER.md +255 -0
- package/docs/1.2.0-DYNAMIC-LIFECYCLE.md +71 -0
- package/docs/1.2.0-EVALUATION-HARNESS.md +51 -0
- package/docs/1.2.0-IMPLEMENTATION-PLAN.md +554 -0
- package/docs/1.2.0-KNOWLEDGE-WORKFLOW-RESEARCH.md +205 -0
- package/docs/1.2.0-KNOWLEDGE-WORKSPACE.md +68 -0
- package/docs/1.2.0-PRODUCT-STORY.md +234 -0
- package/docs/1.2.0-PROVIDER-QUALITY.md +189 -0
- package/docs/1.2.0-WORKFLOW-INHERITANCE.md +101 -0
- package/docs/1.2.0-WORKSET-RETRIEVAL.md +80 -0
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +6 -3
- package/docs/API_REFERENCE.md +25 -6
- package/docs/CONFIGURATION.md +21 -3
- package/docs/README.md +17 -2
- package/docs/dev-log/progress.txt +120 -40
- package/llms-full.txt +16 -2
- package/llms.txt +9 -4
- package/package.json +3 -2
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/src/cli/capability-map.ts +1 -0
- package/src/cli/commands/codegraph.ts +112 -9
- package/src/cli/commands/context.ts +2 -0
- package/src/cli/commands/doctor.ts +73 -4
- package/src/cli/commands/knowledge.ts +282 -0
- package/src/cli/commands/serve-http.ts +12 -1
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/App.tsx +1 -1
- package/src/cli/tui/Panels.tsx +8 -8
- package/src/cli/tui/theme.ts +1 -1
- package/src/cli/tui/views/GraphView.tsx +8 -7
- package/src/cli/tui/views/KnowledgeView.tsx +9 -9
- package/src/codegraph/auto-context.ts +171 -9
- package/src/codegraph/code-state.ts +95 -0
- package/src/codegraph/context-pack.ts +82 -1
- package/src/codegraph/external-provider.ts +581 -0
- package/src/codegraph/lite-provider.ts +64 -19
- package/src/codegraph/project-context.ts +9 -1
- package/src/codegraph/store.ts +154 -6
- package/src/codegraph/types.ts +117 -0
- package/src/config/resolved-config.ts +28 -0
- package/src/config/toml-loader.ts +3 -0
- package/src/config/yaml-loader.ts +6 -0
- package/src/dashboard/server.ts +15 -1
- package/src/evaluation/workset-evaluation.ts +120 -0
- package/src/hooks/handler.ts +48 -6
- package/src/knowledge/claim-store.ts +267 -0
- package/src/knowledge/claims.ts +537 -0
- package/src/knowledge/markdown.ts +129 -0
- package/src/knowledge/types.ts +157 -0
- package/src/knowledge/wiki.ts +524 -0
- package/src/knowledge/workflow-store.ts +168 -0
- package/src/knowledge/workflow-types.ts +95 -0
- package/src/knowledge/workflows.ts +743 -0
- package/src/knowledge/workset.ts +515 -0
- package/src/knowledge/workspace-store.ts +220 -0
- package/src/knowledge/workspace-types.ts +106 -0
- package/src/knowledge/workspace.ts +220 -0
- package/src/memory/observations.ts +19 -0
- package/src/runtime/control-plane-maintenance.ts +5 -0
- package/src/runtime/isolated-maintenance.ts +5 -0
- package/src/runtime/lifecycle-status.ts +102 -0
- package/src/runtime/lifecycle.ts +107 -0
- package/src/runtime/maintenance-jobs.ts +5 -0
- package/src/runtime/project-maintenance.ts +190 -0
- package/src/server/tool-profile.ts +3 -2
- package/src/server.ts +354 -14
- package/src/store/file-lock.ts +24 -4
- package/src/store/sqlite-db.ts +307 -0
- package/src/wiki/generator.ts +4 -2
- package/src/wiki/knowledge-graph.ts +7 -4
- package/src/wiki/types.ts +16 -4
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable, source-qualified knowledge contracts.
|
|
3
|
+
*
|
|
4
|
+
* Claims are deliberately smaller than a wiki page: they record one assertion,
|
|
5
|
+
* its lifecycle, and the evidence that makes the assertion safe to retrieve.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const KNOWLEDGE_CLAIM_SCOPES = [
|
|
9
|
+
'project',
|
|
10
|
+
'workspace',
|
|
11
|
+
'team',
|
|
12
|
+
'workflow',
|
|
13
|
+
'task',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type KnowledgeClaimScope = typeof KNOWLEDGE_CLAIM_SCOPES[number];
|
|
17
|
+
|
|
18
|
+
export const KNOWLEDGE_CLAIM_STATUSES = [
|
|
19
|
+
'active',
|
|
20
|
+
'superseded',
|
|
21
|
+
'disputed',
|
|
22
|
+
'unknown',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type KnowledgeClaimStatus = typeof KNOWLEDGE_CLAIM_STATUSES[number];
|
|
26
|
+
|
|
27
|
+
export const KNOWLEDGE_CLAIM_REVIEW_STATES = [
|
|
28
|
+
'approved',
|
|
29
|
+
'needs-review',
|
|
30
|
+
'draft',
|
|
31
|
+
'rejected',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type KnowledgeClaimReviewState = typeof KNOWLEDGE_CLAIM_REVIEW_STATES[number];
|
|
35
|
+
|
|
36
|
+
export const CLAIM_EVIDENCE_KINDS = [
|
|
37
|
+
'observation',
|
|
38
|
+
'git',
|
|
39
|
+
'code',
|
|
40
|
+
'test',
|
|
41
|
+
'document',
|
|
42
|
+
'workflow',
|
|
43
|
+
'run',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type ClaimEvidenceKind = typeof CLAIM_EVIDENCE_KINDS[number];
|
|
47
|
+
|
|
48
|
+
export const CLAIM_EVIDENCE_RELATIONS = [
|
|
49
|
+
'supports',
|
|
50
|
+
'contradicts',
|
|
51
|
+
'derives',
|
|
52
|
+
'verifies',
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
export type ClaimEvidenceRelation = typeof CLAIM_EVIDENCE_RELATIONS[number];
|
|
56
|
+
|
|
57
|
+
export type KnowledgeClaimOrigin = 'explicit' | 'git' | 'derived' | 'model';
|
|
58
|
+
|
|
59
|
+
export const CLAIM_EVENT_KINDS = [
|
|
60
|
+
'created',
|
|
61
|
+
'evidence-added',
|
|
62
|
+
'conflicted',
|
|
63
|
+
'superseded',
|
|
64
|
+
'requalified',
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
export type ClaimEventKind = typeof CLAIM_EVENT_KINDS[number];
|
|
68
|
+
|
|
69
|
+
export interface KnowledgeClaim {
|
|
70
|
+
id: string;
|
|
71
|
+
projectId: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
predicate: string;
|
|
74
|
+
objectValue: string;
|
|
75
|
+
scope: KnowledgeClaimScope;
|
|
76
|
+
/** Exact normalized assertion identity: subject + predicate + object + scope. */
|
|
77
|
+
claimKey: string;
|
|
78
|
+
/** Normalized competing-assertion identity: subject + predicate + scope. */
|
|
79
|
+
conflictKey: string;
|
|
80
|
+
status: KnowledgeClaimStatus;
|
|
81
|
+
confidence: number;
|
|
82
|
+
observedAt: string;
|
|
83
|
+
validFrom?: string;
|
|
84
|
+
validTo?: string;
|
|
85
|
+
supersededBy?: string;
|
|
86
|
+
reviewState: KnowledgeClaimReviewState;
|
|
87
|
+
origin: KnowledgeClaimOrigin;
|
|
88
|
+
createdAt: string;
|
|
89
|
+
updatedAt: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ClaimEvidenceRef {
|
|
93
|
+
id: string;
|
|
94
|
+
claimId: string;
|
|
95
|
+
evidenceKind: ClaimEvidenceKind;
|
|
96
|
+
evidenceId: string;
|
|
97
|
+
relation: ClaimEvidenceRelation;
|
|
98
|
+
snapshotId?: string;
|
|
99
|
+
locator?: string;
|
|
100
|
+
capturedHash?: string;
|
|
101
|
+
createdAt: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ClaimEvidenceInput {
|
|
105
|
+
evidenceKind: ClaimEvidenceKind;
|
|
106
|
+
evidenceId: string;
|
|
107
|
+
relation: ClaimEvidenceRelation;
|
|
108
|
+
snapshotId?: string;
|
|
109
|
+
locator?: string;
|
|
110
|
+
capturedHash?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface KnowledgeClaimInput {
|
|
114
|
+
projectId: string;
|
|
115
|
+
subject: string;
|
|
116
|
+
predicate: string;
|
|
117
|
+
objectValue: string;
|
|
118
|
+
scope: KnowledgeClaimScope;
|
|
119
|
+
evidence: ClaimEvidenceInput[];
|
|
120
|
+
confidence?: number;
|
|
121
|
+
observedAt?: string;
|
|
122
|
+
validFrom?: string;
|
|
123
|
+
validTo?: string;
|
|
124
|
+
status?: KnowledgeClaimStatus;
|
|
125
|
+
reviewState?: KnowledgeClaimReviewState;
|
|
126
|
+
origin?: KnowledgeClaimOrigin;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ClaimEvent {
|
|
130
|
+
id: string;
|
|
131
|
+
projectId: string;
|
|
132
|
+
claimId: string;
|
|
133
|
+
kind: ClaimEventKind;
|
|
134
|
+
fromStatus?: KnowledgeClaimStatus;
|
|
135
|
+
toStatus?: KnowledgeClaimStatus;
|
|
136
|
+
relatedClaimId?: string;
|
|
137
|
+
detail?: string;
|
|
138
|
+
createdAt: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ClaimConflict {
|
|
142
|
+
conflictKey: string;
|
|
143
|
+
claims: KnowledgeClaim[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ClaimWriteResult {
|
|
147
|
+
claim: KnowledgeClaim;
|
|
148
|
+
created: boolean;
|
|
149
|
+
conflicts: ClaimConflict[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ClaimSelection {
|
|
153
|
+
claims: KnowledgeClaim[];
|
|
154
|
+
cautions: string[];
|
|
155
|
+
tokenCount: number;
|
|
156
|
+
reasons: Record<string, string>;
|
|
157
|
+
}
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { CodeGraphStore } from '../codegraph/store.js';
|
|
5
|
+
import { withFileLock } from '../store/file-lock.js';
|
|
6
|
+
import { ClaimStore } from './claim-store.js';
|
|
7
|
+
import { extractInternalMarkdownLinks, pageContentHash, readKnowledgePage, renderKnowledgePage, resolvePageLink, writeKnowledgePage } from './markdown.js';
|
|
8
|
+
import { KnowledgeWorkspaceStore } from './workspace-store.js';
|
|
9
|
+
import { getKnowledgeWorkspacePaths, resolveKnowledgeWorkspaceFile } from './workspace.js';
|
|
10
|
+
import type {
|
|
11
|
+
KnowledgePage,
|
|
12
|
+
KnowledgePageFrontmatter,
|
|
13
|
+
KnowledgePageRecord,
|
|
14
|
+
KnowledgeProposal,
|
|
15
|
+
KnowledgeProposalReason,
|
|
16
|
+
KnowledgeWorkspace,
|
|
17
|
+
WikiLintIssue,
|
|
18
|
+
WikiLintResult,
|
|
19
|
+
} from './workspace-types.js';
|
|
20
|
+
|
|
21
|
+
export { readKnowledgePage } from './markdown.js';
|
|
22
|
+
|
|
23
|
+
export interface CompileKnowledgeWorkspaceResult {
|
|
24
|
+
proposals: KnowledgeProposal[];
|
|
25
|
+
published: KnowledgePageRecord[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hash(value: string): string {
|
|
29
|
+
return createHash('sha256').update(value).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function now(): string {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function storeFor(workspace: KnowledgeWorkspace): Promise<KnowledgeWorkspaceStore> {
|
|
37
|
+
if (!workspace.dataDir) throw new Error('Knowledge workspace has no operational data directory');
|
|
38
|
+
const store = new KnowledgeWorkspaceStore();
|
|
39
|
+
await store.init(workspace.dataDir);
|
|
40
|
+
return store;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function topicSlug(subject: string): string {
|
|
44
|
+
const safe = subject
|
|
45
|
+
.toLocaleLowerCase('en-US')
|
|
46
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
47
|
+
.replace(/^-+|-+$/g, '')
|
|
48
|
+
.slice(0, 48);
|
|
49
|
+
return (safe || 'topic') + '-' + hash(subject).slice(0, 8);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function groupClaims(claims: ReturnType<ClaimStore['listClaims']>): Map<string, ReturnType<ClaimStore['listClaims']>> {
|
|
53
|
+
const groups = new Map<string, ReturnType<ClaimStore['listClaims']>>();
|
|
54
|
+
for (const claim of claims) {
|
|
55
|
+
const group = groups.get(claim.subject) ?? [];
|
|
56
|
+
group.push(claim);
|
|
57
|
+
groups.set(claim.subject, group);
|
|
58
|
+
}
|
|
59
|
+
return groups;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function selectPublishableClaims(claims: ClaimStore, projectId: string) {
|
|
63
|
+
return claims.listClaims(projectId, { statuses: ['active'], limit: 1_000 })
|
|
64
|
+
.filter(claim => claim.reviewState === 'approved');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function evidenceRefs(claims: ReturnType<ClaimStore['listClaims']>, store: ClaimStore): string[] {
|
|
68
|
+
const refs = new Set<string>();
|
|
69
|
+
for (const claim of claims) {
|
|
70
|
+
for (const evidence of store.listEvidence(claim.id)) {
|
|
71
|
+
refs.add('claim:' + claim.id + ':evidence:' + evidence.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...refs].sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snapshotIdForClaims(claims: ReturnType<ClaimStore['listClaims']>, store: ClaimStore): string | undefined {
|
|
78
|
+
const ids = new Set<string>();
|
|
79
|
+
for (const claim of claims) {
|
|
80
|
+
for (const evidence of store.listEvidence(claim.id)) {
|
|
81
|
+
if (evidence.snapshotId) ids.add(evidence.snapshotId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ids.size === 1 ? [...ids][0] : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sourceHashForClaims(claims: ReturnType<ClaimStore['listClaims']>, store: ClaimStore): string {
|
|
88
|
+
const source = claims.map(claim => ({
|
|
89
|
+
id: claim.id,
|
|
90
|
+
status: claim.status,
|
|
91
|
+
confidence: claim.confidence,
|
|
92
|
+
evidence: store.listEvidence(claim.id).map(item => ({
|
|
93
|
+
id: item.id,
|
|
94
|
+
kind: item.evidenceKind,
|
|
95
|
+
evidenceId: item.evidenceId,
|
|
96
|
+
relation: item.relation,
|
|
97
|
+
snapshotId: item.snapshotId ?? '',
|
|
98
|
+
capturedHash: item.capturedHash ?? '',
|
|
99
|
+
})),
|
|
100
|
+
}));
|
|
101
|
+
return hash(JSON.stringify(source));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderTopicBody(title: string, claims: ReturnType<ClaimStore['listClaims']>, store: ClaimStore): string {
|
|
105
|
+
const lines = [
|
|
106
|
+
'# ' + title,
|
|
107
|
+
'',
|
|
108
|
+
'This page is compiled from approved source-qualified claims.',
|
|
109
|
+
'',
|
|
110
|
+
'## Current claims',
|
|
111
|
+
'',
|
|
112
|
+
];
|
|
113
|
+
for (const claim of claims) {
|
|
114
|
+
lines.push('- ' + claim.predicate + ': ' + claim.objectValue + ' (claim ' + claim.id + ')');
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push('## Evidence');
|
|
118
|
+
lines.push('');
|
|
119
|
+
for (const claim of claims) {
|
|
120
|
+
lines.push('### Claim ' + claim.id);
|
|
121
|
+
const evidence = store.listEvidence(claim.id);
|
|
122
|
+
if (evidence.length === 0) {
|
|
123
|
+
lines.push('- Missing evidence.');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const item of evidence) {
|
|
127
|
+
const location = item.locator ? ' at ' + item.locator : '';
|
|
128
|
+
const snapshot = item.snapshotId ? ' (snapshot ' + item.snapshotId + ')' : '';
|
|
129
|
+
lines.push('- ' + item.evidenceKind + ': ' + item.evidenceId + location + snapshot);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function candidatePage(
|
|
137
|
+
workspace: KnowledgeWorkspace,
|
|
138
|
+
title: string,
|
|
139
|
+
claims: ReturnType<ClaimStore['listClaims']>,
|
|
140
|
+
store: ClaimStore,
|
|
141
|
+
): { page: KnowledgePage; content: string; targetRelativePath: string; proposalRelativePath: string } {
|
|
142
|
+
const sourceHash = sourceHashForClaims(claims, store);
|
|
143
|
+
const generatedAt = now();
|
|
144
|
+
const slug = topicSlug(title);
|
|
145
|
+
const targetRelativePath = 'pages/' + slug + '.md';
|
|
146
|
+
const proposalRelativePath = 'proposals/' + slug + '.md';
|
|
147
|
+
const frontmatter: KnowledgePageFrontmatter = {
|
|
148
|
+
id: 'page:' + hash(workspace.projectId + ':' + title).slice(0, 24),
|
|
149
|
+
title,
|
|
150
|
+
kind: 'topic',
|
|
151
|
+
status: 'proposed',
|
|
152
|
+
reviewState: 'needs-review',
|
|
153
|
+
claimIds: claims.map(claim => claim.id).sort(),
|
|
154
|
+
evidenceRefs: evidenceRefs(claims, store),
|
|
155
|
+
...(snapshotIdForClaims(claims, store) ? { snapshotId: snapshotIdForClaims(claims, store) } : {}),
|
|
156
|
+
tags: ['generated', 'topic'],
|
|
157
|
+
sourceHash,
|
|
158
|
+
generatedAt,
|
|
159
|
+
updatedAt: generatedAt,
|
|
160
|
+
};
|
|
161
|
+
const content = renderKnowledgePage(frontmatter, renderTopicBody(title, claims, store));
|
|
162
|
+
const proposalPath = resolveKnowledgeWorkspaceFile(workspace, proposalRelativePath);
|
|
163
|
+
return {
|
|
164
|
+
page: {
|
|
165
|
+
absolutePath: proposalPath,
|
|
166
|
+
relativePath: proposalRelativePath,
|
|
167
|
+
frontmatter,
|
|
168
|
+
body: renderTopicBody(title, claims, store),
|
|
169
|
+
contentHash: pageContentHash(content),
|
|
170
|
+
links: [],
|
|
171
|
+
},
|
|
172
|
+
content,
|
|
173
|
+
targetRelativePath,
|
|
174
|
+
proposalRelativePath,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fileHashIfPresent(filePath: string): Promise<string | undefined> {
|
|
179
|
+
try {
|
|
180
|
+
return pageContentHash(await fs.readFile(filePath, 'utf8'));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const code = error && typeof error === 'object' && 'code' in error
|
|
183
|
+
? (error as { code?: string }).code
|
|
184
|
+
: undefined;
|
|
185
|
+
if (code === 'ENOENT') return undefined;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function writeIndex(workspace: KnowledgeWorkspace, store: KnowledgeWorkspaceStore): Promise<void> {
|
|
191
|
+
const paths = getKnowledgeWorkspacePaths(workspace);
|
|
192
|
+
const published = store.listPages(workspace.id)
|
|
193
|
+
.filter(page => page.status === 'active')
|
|
194
|
+
.sort((left, right) => left.title.localeCompare(right.title));
|
|
195
|
+
const pending = store.listProposals(workspace.id, 'pending')
|
|
196
|
+
.sort((left, right) => left.proposalPath.localeCompare(right.proposalPath));
|
|
197
|
+
const lines = ['# Knowledge Workspace', '', '## Published pages', ''];
|
|
198
|
+
if (published.length === 0) {
|
|
199
|
+
lines.push('No published pages yet.');
|
|
200
|
+
} else {
|
|
201
|
+
for (const page of published) lines.push('- [' + page.title + '](' + page.relativePath + ')');
|
|
202
|
+
}
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('## Review queue');
|
|
205
|
+
lines.push('');
|
|
206
|
+
if (pending.length === 0) {
|
|
207
|
+
lines.push('No pending proposals.');
|
|
208
|
+
} else {
|
|
209
|
+
for (const proposal of pending) {
|
|
210
|
+
const relative = path.relative(paths.root, proposal.proposalPath).split(path.sep).join('/');
|
|
211
|
+
lines.push('- [' + path.basename(proposal.proposalPath, '.md') + '](' + relative + ')');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
lines.push('');
|
|
215
|
+
await writeKnowledgePage(paths.index, lines.join('\n'));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function appendLog(workspace: KnowledgeWorkspace, line: string): Promise<void> {
|
|
219
|
+
const logPath = getKnowledgeWorkspacePaths(workspace).log;
|
|
220
|
+
await fs.appendFile(logPath, '- ' + now() + ' ' + line + '\n', 'utf8');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toPageRecord(
|
|
224
|
+
workspace: KnowledgeWorkspace,
|
|
225
|
+
page: KnowledgePage,
|
|
226
|
+
status: KnowledgePageRecord['status'],
|
|
227
|
+
contentHash: string,
|
|
228
|
+
manualContentHash?: string,
|
|
229
|
+
): KnowledgePageRecord {
|
|
230
|
+
return {
|
|
231
|
+
id: page.frontmatter.id,
|
|
232
|
+
workspaceId: workspace.id,
|
|
233
|
+
relativePath: page.relativePath.replace(/^proposals\//, 'pages/'),
|
|
234
|
+
title: page.frontmatter.title,
|
|
235
|
+
kind: page.frontmatter.kind,
|
|
236
|
+
status,
|
|
237
|
+
reviewState: page.frontmatter.reviewState,
|
|
238
|
+
contentHash,
|
|
239
|
+
sourceHash: page.frontmatter.sourceHash,
|
|
240
|
+
claimIds: page.frontmatter.claimIds,
|
|
241
|
+
...(page.frontmatter.snapshotId ? { snapshotId: page.frontmatter.snapshotId } : {}),
|
|
242
|
+
tags: page.frontmatter.tags,
|
|
243
|
+
generatedAt: page.frontmatter.generatedAt,
|
|
244
|
+
updatedAt: page.frontmatter.updatedAt,
|
|
245
|
+
...(manualContentHash ? { manualContentHash } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function compileKnowledgeWorkspace(input: {
|
|
250
|
+
workspace: KnowledgeWorkspace;
|
|
251
|
+
claims: ClaimStore;
|
|
252
|
+
}): Promise<CompileKnowledgeWorkspaceResult> {
|
|
253
|
+
const store = await storeFor(input.workspace);
|
|
254
|
+
const published: KnowledgePageRecord[] = [];
|
|
255
|
+
const proposals: KnowledgeProposal[] = [];
|
|
256
|
+
const claims = selectPublishableClaims(input.claims, input.workspace.projectId);
|
|
257
|
+
|
|
258
|
+
await withFileLock(getKnowledgeWorkspacePaths(input.workspace).root, async () => {
|
|
259
|
+
for (const [title, group] of groupClaims(claims)) {
|
|
260
|
+
const candidate = candidatePage(input.workspace, title, group, input.claims);
|
|
261
|
+
const targetPath = resolveKnowledgeWorkspaceFile(input.workspace, candidate.targetRelativePath);
|
|
262
|
+
const proposalPath = resolveKnowledgeWorkspaceFile(input.workspace, candidate.proposalRelativePath);
|
|
263
|
+
let existing = store.getPage(input.workspace.id, candidate.targetRelativePath);
|
|
264
|
+
const actualTargetHash = await fileHashIfPresent(targetPath);
|
|
265
|
+
if (actualTargetHash && !existing) {
|
|
266
|
+
const manualRecord = {
|
|
267
|
+
...toPageRecord(input.workspace, candidate.page, 'active', actualTargetHash, actualTargetHash),
|
|
268
|
+
reviewState: 'needs-review' as const,
|
|
269
|
+
sourceHash: 'manual:' + actualTargetHash,
|
|
270
|
+
};
|
|
271
|
+
store.upsertPage(manualRecord);
|
|
272
|
+
existing = manualRecord;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
actualTargetHash
|
|
277
|
+
&& existing
|
|
278
|
+
&& existing.status === 'active'
|
|
279
|
+
&& !existing.manualContentHash
|
|
280
|
+
&& actualTargetHash === existing.contentHash
|
|
281
|
+
&& existing.sourceHash === candidate.page.frontmatter.sourceHash
|
|
282
|
+
) {
|
|
283
|
+
published.push(existing);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let reason: KnowledgeProposalReason = 'new-page';
|
|
288
|
+
let baseContentHash: string | undefined;
|
|
289
|
+
if (actualTargetHash) {
|
|
290
|
+
baseContentHash = existing?.contentHash;
|
|
291
|
+
if (
|
|
292
|
+
!existing
|
|
293
|
+
|| existing.status !== 'active'
|
|
294
|
+
|| !!existing.manualContentHash
|
|
295
|
+
|| actualTargetHash !== existing.contentHash
|
|
296
|
+
) {
|
|
297
|
+
reason = 'manual-edit-protected';
|
|
298
|
+
} else {
|
|
299
|
+
reason = 'source-changed';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!actualTargetHash) {
|
|
304
|
+
store.upsertPage(toPageRecord(input.workspace, candidate.page, 'proposed', candidate.page.contentHash));
|
|
305
|
+
}
|
|
306
|
+
await writeKnowledgePage(proposalPath, candidate.content);
|
|
307
|
+
const proposal = store.createProposal({
|
|
308
|
+
workspaceId: input.workspace.id,
|
|
309
|
+
pageId: candidate.page.frontmatter.id,
|
|
310
|
+
targetPath,
|
|
311
|
+
proposalPath,
|
|
312
|
+
...(baseContentHash ? { baseContentHash } : {}),
|
|
313
|
+
sourceHash: candidate.page.frontmatter.sourceHash,
|
|
314
|
+
reason,
|
|
315
|
+
});
|
|
316
|
+
proposals.push(proposal);
|
|
317
|
+
await appendLog(input.workspace, 'proposed ' + candidate.targetRelativePath + ' (' + reason + ').');
|
|
318
|
+
}
|
|
319
|
+
store.markWorkspaceCompiled(input.workspace.id);
|
|
320
|
+
await writeIndex(input.workspace, store);
|
|
321
|
+
});
|
|
322
|
+
return { proposals, published };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function applyKnowledgeProposal(input: {
|
|
326
|
+
workspace: KnowledgeWorkspace;
|
|
327
|
+
proposalId: string;
|
|
328
|
+
/** Explicit review acknowledgement before replacing a manually edited page. */
|
|
329
|
+
allowManualOverwrite?: boolean;
|
|
330
|
+
}): Promise<{ proposal: KnowledgeProposal; targetPath: string }> {
|
|
331
|
+
const store = await storeFor(input.workspace);
|
|
332
|
+
const proposal = store.getProposal(input.proposalId);
|
|
333
|
+
if (!proposal || proposal.workspaceId !== input.workspace.id) {
|
|
334
|
+
throw new Error('Knowledge proposal was not found for this workspace');
|
|
335
|
+
}
|
|
336
|
+
if (proposal.status !== 'pending') throw new Error('Knowledge proposal is not pending review');
|
|
337
|
+
const root = getKnowledgeWorkspacePaths(input.workspace).root;
|
|
338
|
+
const targetPath = path.resolve(proposal.targetPath);
|
|
339
|
+
const proposalPath = path.resolve(proposal.proposalPath);
|
|
340
|
+
if (!targetPath.startsWith(root + path.sep) || !proposalPath.startsWith(root + path.sep)) {
|
|
341
|
+
throw new Error('Knowledge proposal path escapes its workspace');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await withFileLock(root, async () => {
|
|
345
|
+
const actualTargetHash = await fileHashIfPresent(targetPath);
|
|
346
|
+
if (
|
|
347
|
+
proposal.baseContentHash
|
|
348
|
+
&& actualTargetHash !== proposal.baseContentHash
|
|
349
|
+
&& !input.allowManualOverwrite
|
|
350
|
+
) {
|
|
351
|
+
throw new Error('The target page has manual changes; review the proposal before applying it');
|
|
352
|
+
}
|
|
353
|
+
if (!proposal.baseContentHash && actualTargetHash && !input.allowManualOverwrite) {
|
|
354
|
+
throw new Error('The target page already exists and has no known safe base revision');
|
|
355
|
+
}
|
|
356
|
+
const proposalPage = await readKnowledgePage(proposalPath, root);
|
|
357
|
+
const activeFrontmatter: KnowledgePageFrontmatter = {
|
|
358
|
+
...proposalPage.frontmatter,
|
|
359
|
+
status: 'active',
|
|
360
|
+
reviewState: 'approved',
|
|
361
|
+
updatedAt: now(),
|
|
362
|
+
};
|
|
363
|
+
const content = renderKnowledgePage(activeFrontmatter, proposalPage.body);
|
|
364
|
+
const contentHash = await writeKnowledgePage(targetPath, content);
|
|
365
|
+
const activePage = await readKnowledgePage(targetPath, root);
|
|
366
|
+
const record = toPageRecord(input.workspace, activePage, 'active', contentHash);
|
|
367
|
+
store.upsertPage(record);
|
|
368
|
+
store.replacePageClaims(record.id, record.claimIds);
|
|
369
|
+
store.replacePageLinks(record.id, activePage.links.map(link => resolvePageLink(record.relativePath, link)).filter((link): link is string => !!link));
|
|
370
|
+
store.markProposalApplied(proposal.id);
|
|
371
|
+
await appendLog(
|
|
372
|
+
input.workspace,
|
|
373
|
+
'applied ' + record.relativePath + (input.allowManualOverwrite ? ' after explicit manual overwrite review.' : '.'),
|
|
374
|
+
);
|
|
375
|
+
await writeIndex(input.workspace, store);
|
|
376
|
+
});
|
|
377
|
+
return { proposal: store.getProposal(proposal.id)!, targetPath };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function markdownFiles(directory: string): Promise<string[]> {
|
|
381
|
+
const files: string[] = [];
|
|
382
|
+
let entries: Array<import('node:fs').Dirent>;
|
|
383
|
+
try {
|
|
384
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
385
|
+
} catch {
|
|
386
|
+
return files;
|
|
387
|
+
}
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
const filePath = path.join(directory, entry.name);
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
files.push(...await markdownFiles(filePath));
|
|
392
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
393
|
+
files.push(filePath);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return files;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function lintKnowledgeWorkspace(input: {
|
|
400
|
+
workspace: KnowledgeWorkspace;
|
|
401
|
+
claims: ClaimStore;
|
|
402
|
+
codeStore?: CodeGraphStore;
|
|
403
|
+
}): Promise<WikiLintResult> {
|
|
404
|
+
const store = await storeFor(input.workspace);
|
|
405
|
+
const paths = getKnowledgeWorkspacePaths(input.workspace);
|
|
406
|
+
const issues: WikiLintIssue[] = [];
|
|
407
|
+
const validPages: KnowledgePage[] = [];
|
|
408
|
+
const files = await markdownFiles(paths.pages);
|
|
409
|
+
|
|
410
|
+
for (const filePath of files) {
|
|
411
|
+
try {
|
|
412
|
+
validPages.push(await readKnowledgePage(filePath, paths.root));
|
|
413
|
+
} catch (error) {
|
|
414
|
+
issues.push({
|
|
415
|
+
kind: 'malformed-frontmatter',
|
|
416
|
+
severity: 'error',
|
|
417
|
+
message: error instanceof Error ? error.message : 'Malformed knowledge page.',
|
|
418
|
+
relativePath: path.relative(paths.root, filePath).split(path.sep).join('/'),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const indexed = new Set<string>();
|
|
424
|
+
try {
|
|
425
|
+
const index = await fs.readFile(paths.index, 'utf8');
|
|
426
|
+
for (const link of extractInternalMarkdownLinks(index)) {
|
|
427
|
+
const resolved = resolvePageLink('index.md', link);
|
|
428
|
+
if (resolved) indexed.add(resolved);
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
issues.push({
|
|
432
|
+
kind: 'broken-link',
|
|
433
|
+
severity: 'error',
|
|
434
|
+
message: 'Knowledge index is missing.',
|
|
435
|
+
relativePath: 'index.md',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const latestSnapshotId = input.codeStore?.latestSnapshot(input.workspace.projectId)?.id;
|
|
440
|
+
for (const page of validPages) {
|
|
441
|
+
if (!indexed.has(page.relativePath)) {
|
|
442
|
+
issues.push({
|
|
443
|
+
kind: 'orphan-page',
|
|
444
|
+
severity: 'warning',
|
|
445
|
+
message: 'Page is not linked from index.md.',
|
|
446
|
+
relativePath: page.relativePath,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
for (const link of page.links) {
|
|
450
|
+
const target = resolvePageLink(page.relativePath, link);
|
|
451
|
+
if (!target || !await fileHashIfPresent(resolveKnowledgeWorkspaceFile(input.workspace, target))) {
|
|
452
|
+
issues.push({
|
|
453
|
+
kind: 'broken-link',
|
|
454
|
+
severity: 'error',
|
|
455
|
+
message: 'Internal Markdown link does not resolve to a workspace page.',
|
|
456
|
+
relativePath: page.relativePath,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const claimId of page.frontmatter.claimIds) {
|
|
461
|
+
const claim = input.claims.getClaim(claimId);
|
|
462
|
+
if (!claim) {
|
|
463
|
+
issues.push({
|
|
464
|
+
kind: 'missing-claim',
|
|
465
|
+
severity: 'error',
|
|
466
|
+
message: 'Page references a claim that is not present in the ledger.',
|
|
467
|
+
relativePath: page.relativePath,
|
|
468
|
+
claimId,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (input.claims.listEvidence(claim.id).length === 0) {
|
|
473
|
+
issues.push({
|
|
474
|
+
kind: 'missing-evidence',
|
|
475
|
+
severity: 'error',
|
|
476
|
+
message: 'Page claim has no source evidence.',
|
|
477
|
+
relativePath: page.relativePath,
|
|
478
|
+
claimId,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (claim.status === 'superseded') {
|
|
482
|
+
issues.push({
|
|
483
|
+
kind: 'superseded-claim',
|
|
484
|
+
severity: 'error',
|
|
485
|
+
message: 'Page still presents a superseded primary claim.',
|
|
486
|
+
relativePath: page.relativePath,
|
|
487
|
+
claimId,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
if (claim.status === 'disputed') {
|
|
491
|
+
issues.push({
|
|
492
|
+
kind: 'unresolved-conflict',
|
|
493
|
+
severity: 'error',
|
|
494
|
+
message: 'Page claim has an unresolved competing assertion.',
|
|
495
|
+
relativePath: page.relativePath,
|
|
496
|
+
claimId,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (latestSnapshotId && page.frontmatter.snapshotId && page.frontmatter.snapshotId !== latestSnapshotId) {
|
|
501
|
+
issues.push({
|
|
502
|
+
kind: 'stale-snapshot',
|
|
503
|
+
severity: 'warning',
|
|
504
|
+
message: 'Page was compiled against an older code snapshot.',
|
|
505
|
+
relativePath: page.relativePath,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const proposal of store.listProposals(input.workspace.id, 'pending')) {
|
|
511
|
+
if (proposal.reason === 'manual-edit-protected') {
|
|
512
|
+
issues.push({
|
|
513
|
+
kind: 'manual-edit-protected',
|
|
514
|
+
severity: 'warning',
|
|
515
|
+
message: 'A newer proposal was held because the target page has manual edits.',
|
|
516
|
+
relativePath: path.relative(paths.root, proposal.targetPath).split(path.sep).join('/'),
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const valid = !issues.some(issue => issue.severity === 'error');
|
|
522
|
+
store.markWorkspaceLinted(input.workspace.id, valid ? 'ready' : 'needs-review');
|
|
523
|
+
return { valid, pagesScanned: validPages.length, issues };
|
|
524
|
+
}
|