@prodero/curator 0.0.0-stage → 0.1.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/ARCHITECTURE.md +298 -0
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +9 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/SECURITY.md +7 -0
- package/bin/prodero +7 -0
- package/dist/cli.js +228 -0
- package/dist/context-instructions.js +2 -0
- package/dist/contracts.js +50 -0
- package/dist/conversation-presentation.js +262 -0
- package/dist/core/agent-instructions.js +39 -0
- package/dist/core/applicability-label.js +11 -0
- package/dist/core/applicability.js +143 -0
- package/dist/core/bootstrap.js +129 -0
- package/dist/core/consolidation.js +315 -0
- package/dist/core/constants.js +21 -0
- package/dist/core/context-allocation.js +54 -0
- package/dist/core/context-snapshot.js +12 -0
- package/dist/core/context.js +489 -0
- package/dist/core/curation-clauses.js +68 -0
- package/dist/core/curation-evidence.js +98 -0
- package/dist/core/curation-graphical.js +105 -0
- package/dist/core/curation-review-presentation.js +4 -0
- package/dist/core/curation-review-state.js +62 -0
- package/dist/core/curation-review.js +573 -0
- package/dist/core/curation-revision.js +106 -0
- package/dist/core/curation-semantic-contract.js +239 -0
- package/dist/core/curation-semantic-jobs.js +280 -0
- package/dist/core/diagnostics.js +17 -0
- package/dist/core/format.js +114 -0
- package/dist/core/ids.js +6 -0
- package/dist/core/imports.js +169 -0
- package/dist/core/index.js +93 -0
- package/dist/core/instruction-review.js +86 -0
- package/dist/core/integrity-files.js +174 -0
- package/dist/core/intelligence.js +95 -0
- package/dist/core/knowledge-analysis.js +46 -0
- package/dist/core/knowledge-authority.js +143 -0
- package/dist/core/knowledge-curation.js +316 -0
- package/dist/core/knowledge-engine.js +475 -0
- package/dist/core/knowledge-graph.js +191 -0
- package/dist/core/knowledge-lifecycle.js +212 -0
- package/dist/core/knowledge-management.js +113 -0
- package/dist/core/knowledge-migration.js +84 -0
- package/dist/core/knowledge-policy.js +38 -0
- package/dist/core/knowledge-provenance.js +90 -0
- package/dist/core/knowledge-write-policy.js +85 -0
- package/dist/core/language.js +14 -0
- package/dist/core/matching.js +6 -0
- package/dist/core/memory-index.js +239 -0
- package/dist/core/memory-intelligence.js +288 -0
- package/dist/core/memory-link-repair.js +66 -0
- package/dist/core/memory-operations.js +122 -0
- package/dist/core/memory-pressure.js +212 -0
- package/dist/core/memory-sync-curator.js +62 -0
- package/dist/core/memory-sync.js +463 -0
- package/dist/core/memory-transformation-bundle.js +163 -0
- package/dist/core/memory-transformation-proposal.js +135 -0
- package/dist/core/memory-transformation.js +108 -0
- package/dist/core/memory.js +186 -0
- package/dist/core/migrations.js +569 -0
- package/dist/core/path-scope.js +27 -0
- package/dist/core/plugin-files.js +116 -0
- package/dist/core/project-root.js +39 -0
- package/dist/core/project.js +634 -0
- package/dist/core/promotion.js +279 -0
- package/dist/core/reconciliation.js +61 -0
- package/dist/core/secrets.js +67 -0
- package/dist/core/semantic-safety.js +33 -0
- package/dist/core/session-index.js +112 -0
- package/dist/core/session-review.js +150 -0
- package/dist/core/sessions.js +289 -0
- package/dist/core/settings.js +76 -0
- package/dist/core/skill-curation.js +239 -0
- package/dist/core/skill-polish.js +41 -0
- package/dist/core/skills.js +368 -0
- package/dist/core/storage.js +408 -0
- package/dist/core/sync-transaction.js +254 -0
- package/dist/core/telemetry.js +64 -0
- package/dist/core/templates.js +53 -0
- package/dist/core/types.js +2 -0
- package/dist/core/update.js +320 -0
- package/dist/curation-review-adapter.js +92 -0
- package/dist/diagnostic-boundary.js +54 -0
- package/dist/filesystem/permissions.js +39 -0
- package/dist/hosts/adapters.js +119 -0
- package/dist/hosts/files.js +133 -0
- package/dist/hosts/manager.js +150 -0
- package/dist/hosts/public-entry.js +47 -0
- package/dist/hosts/runtime-command.js +66 -0
- package/dist/hosts/runtime.js +139 -0
- package/dist/hosts/wizard.js +114 -0
- package/dist/knowledge-table.js +38 -0
- package/dist/mcp-development.js +69 -0
- package/dist/mcp-workspace.js +141 -0
- package/dist/memory-presentation.js +172 -0
- package/dist/plugins/api.js +2 -0
- package/dist/plugins/context.js +20 -0
- package/dist/plugins/mcp.js +72 -0
- package/dist/plugins/registry.js +106 -0
- package/dist/presentation-cards.js +32 -0
- package/dist/presentation-server.js +79 -0
- package/dist/presentation-trace.js +174 -0
- package/dist/presentation.js +412 -0
- package/dist/providers/mcp-sampling.js +45 -0
- package/dist/server.js +656 -0
- package/dist/settings-definition.js +22 -0
- package/dist/skill-instructions.js +2 -0
- package/dist-ui/index.html +214 -0
- package/docs/APPLICABILITY.md +151 -0
- package/docs/COMPATIBILITY.md +49 -0
- package/docs/CONTEXT-SELECTION-V2.md +61 -0
- package/docs/CURATION-APP-REVIEW.md +156 -0
- package/docs/CURATION-CLAUSE-REFINEMENT.md +45 -0
- package/docs/IMPORTER-CURATION.md +142 -0
- package/docs/KNOWLEDGE-AUTHORITY.md +165 -0
- package/docs/KNOWLEDGE-SCHEMA-V2.md +150 -0
- package/docs/MIGRATIONS.md +99 -0
- package/docs/PRIVACY.md +28 -0
- package/docs/RECOVERY.md +56 -0
- package/docs/RELEASING.md +40 -0
- package/docs/hosts.md +134 -0
- package/docs/plugins.md +83 -0
- package/docs/ui-themes.md +43 -0
- package/instructions/AGENTS.md +33 -0
- package/package.json +88 -4
- package/plugins/migrate_ai_studio/README.md +101 -0
- package/plugins/migrate_ai_studio/index.cjs +396 -0
- package/plugins/migrate_ai_studio/instructions/audit.md +25 -0
- package/plugins/migrate_ai_studio/instructions/classify.md +41 -0
- package/plugins/migrate_ai_studio/instructions/migrate.md +34 -0
- package/plugins/migrate_ai_studio/instructions/verify.md +20 -0
- package/plugins/migrate_ai_studio/plugin.json +11 -0
- package/plugins/migrate_ai_studio/schema.cjs +20 -0
- package/plugins/migrate_ai_studio/ui/dist/index.html +77 -0
- package/prodero.release.json +5 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PRODERO_SCHEMA_VERSION } from "./constants.js";
|
|
2
|
+
import { loadMemoryCatalog } from "./memory-index.js";
|
|
3
|
+
import { loadKnowledgeGraph } from "./knowledge-graph.js";
|
|
4
|
+
import { checkedDataPath, ensureRuntimeDirectories, writeFileAtomic } from "./storage.js";
|
|
5
|
+
import { listProjectSkills, skillContext } from "./skills.js";
|
|
6
|
+
function line(item) { return `- [${item.title}](memory/${item.filename})`; }
|
|
7
|
+
export const MAX_INDEX_REQUIRED_ITEMS = 16;
|
|
8
|
+
export const MAX_INDEX_CONTEXTUAL_ITEMS = 128;
|
|
9
|
+
function diverseContextual(items, limit) {
|
|
10
|
+
const groups = new Map();
|
|
11
|
+
for (const item of items) {
|
|
12
|
+
const key = item.scopeRef ?? item.scope;
|
|
13
|
+
const group = groups.get(key) ?? [];
|
|
14
|
+
group.push(item);
|
|
15
|
+
groups.set(key, group);
|
|
16
|
+
}
|
|
17
|
+
const orderedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
18
|
+
const selected = [];
|
|
19
|
+
for (let offset = 0; selected.length < limit; offset += 1) {
|
|
20
|
+
let added = false;
|
|
21
|
+
for (const [, group] of orderedGroups) {
|
|
22
|
+
if (group[offset]) {
|
|
23
|
+
selected.push(group[offset]);
|
|
24
|
+
added = true;
|
|
25
|
+
}
|
|
26
|
+
if (selected.length >= limit)
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
if (!added)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return selected;
|
|
33
|
+
}
|
|
34
|
+
async function skillReferences(root) {
|
|
35
|
+
return (await listProjectSkills(root)).filter((skill) => skill.status === "active").map(skillContext);
|
|
36
|
+
}
|
|
37
|
+
export async function buildIndex(root) {
|
|
38
|
+
await ensureRuntimeDirectories(root);
|
|
39
|
+
const catalog = await loadMemoryCatalog(root);
|
|
40
|
+
const active = catalog.summaries.filter((item) => item.status === "active" && item.layer === "versioned");
|
|
41
|
+
const local = catalog.summaries.filter((item) => item.status === "active" && item.layer === "local");
|
|
42
|
+
const allRequired = active
|
|
43
|
+
.filter((item) => item.scope === "global" && item.contextPolicy === "required")
|
|
44
|
+
.sort((a, b) => (a.priority === b.priority ? 0 : a.priority === "critical" ? -1 : 1) || b.updatedAt.localeCompare(a.updatedAt) || a.title.localeCompare(b.title) || a.id.localeCompare(b.id));
|
|
45
|
+
const required = allRequired.slice(0, MAX_INDEX_REQUIRED_ITEMS);
|
|
46
|
+
const allRequiredIds = new Set(allRequired.map((item) => item.id));
|
|
47
|
+
const allContextual = active
|
|
48
|
+
.filter((item) => !allRequiredIds.has(item.id))
|
|
49
|
+
.sort((a, b) => (a.scopeRef ?? a.scope).localeCompare(b.scopeRef ?? b.scope) || a.title.localeCompare(b.title) || a.id.localeCompare(b.id));
|
|
50
|
+
const contextual = diverseContextual(allContextual, MAX_INDEX_CONTEXTUAL_ITEMS);
|
|
51
|
+
const skills = await skillReferences(root);
|
|
52
|
+
const text = `---
|
|
53
|
+
prodero_schema: ${PRODERO_SCHEMA_VERSION}
|
|
54
|
+
kind: project-index
|
|
55
|
+
generated: true
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# Prodero · Project Intelligence
|
|
59
|
+
|
|
60
|
+
> Compact router for progressive context loading. Open only references relevant to the current task.
|
|
61
|
+
|
|
62
|
+
## Required Global Context
|
|
63
|
+
|
|
64
|
+
${required.length ? required.map(line).join("\n") : "_No required global knowledge._"}${allRequired.length > required.length ? `\n\n_${allRequired.length - required.length} additional required item(s) are available through bounded context resolution._` : ""}
|
|
65
|
+
|
|
66
|
+
## Contextual Knowledge
|
|
67
|
+
|
|
68
|
+
${contextual.length ? contextual.map((item) => `${line(item)} — ${item.scope}${item.scopeRef ? `: ${item.scopeRef}` : ""}; ${item.category}; ${item.priority}`).join("\n") : "_No contextual knowledge._"}${allContextual.length > contextual.length ? `\n\n_${allContextual.length - contextual.length} additional contextual item(s) are available through query-ranked resolution._` : ""}
|
|
69
|
+
|
|
70
|
+
## Local Knowledge
|
|
71
|
+
|
|
72
|
+
- [Developer-local knowledge](local/index.md) — ignored by Git and retrieved together with Versioned Knowledge.
|
|
73
|
+
|
|
74
|
+
## Project Skills
|
|
75
|
+
|
|
76
|
+
${skills.length ? skills.slice(0, 32).map((skill) => `- [${skill.title}](${skill.path})`).join("\n") : "_No project skills._"}
|
|
77
|
+
|
|
78
|
+
## Loading Rules
|
|
79
|
+
|
|
80
|
+
1. Use bounded context resolution; required global knowledge is priority-ranked and budgeted.
|
|
81
|
+
2. Follow only bounded graph relationships selected by context resolution; never recursively load the graph or unrelated scopes.
|
|
82
|
+
3. Load relevant project skills; do not recursively load \`.prodero/\`.
|
|
83
|
+
4. Use session memory as the write path. Sync produces Git-ignored Local Knowledge.
|
|
84
|
+
5. Use the promotion review workflow for Versioned Knowledge. Review repository changes before committing or pushing; Prodero does neither.
|
|
85
|
+
`;
|
|
86
|
+
await writeFileAtomic(await checkedDataPath(root, "index.md"), text);
|
|
87
|
+
const localText = `# Prodero · Local Knowledge\n\n> Developer-local canonical knowledge. This directory is ignored by Git.\n\n${local.length ? local.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)).map((item) => `- [${item.title}](memory/${item.filename}) — ${item.scope}${item.scopeRef ? `: ${item.scopeRef}` : ""}; ${item.category}; ${item.priority}`).join("\n") : "_No active local knowledge._"}\n`;
|
|
88
|
+
await writeFileAtomic(await checkedDataPath(root, "local", "index.md"), localText);
|
|
89
|
+
await loadKnowledgeGraph(root, catalog);
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
export { skillReferences };
|
|
93
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { applicabilityKey } from "./applicability.js";
|
|
2
|
+
import { loadMemoryCatalog } from './memory-index.js';
|
|
3
|
+
import { planAssistedSync } from './knowledge-engine.js';
|
|
4
|
+
import { exactKnowledge } from './semantic-safety.js';
|
|
5
|
+
import { INTELLIGENCE_LIMITS } from './intelligence.js';
|
|
6
|
+
import { withSyncLock, recoverSyncTransaction, commitSyncTransaction, fileHash, safeProjectPath } from './sync-transaction.js';
|
|
7
|
+
import { listCheckpoints } from './storage.js';
|
|
8
|
+
import { sortableId } from './ids.js';
|
|
9
|
+
import { parseKnowledge } from './memory.js';
|
|
10
|
+
import { buildIndex } from './index.js';
|
|
11
|
+
import { estimateTokens, knowledgeText } from './telemetry.js';
|
|
12
|
+
export async function reviewOmittedInstructions(selection, offset = 0, criticalOnly = false) {
|
|
13
|
+
const catalog = await loadMemoryCatalog(selection.root);
|
|
14
|
+
const omitted = selection.omitted.filter(i => !criticalOnly || i.priority === 'critical');
|
|
15
|
+
const page = omitted.slice(offset, offset + 12);
|
|
16
|
+
const items = await Promise.all(page.map(async (selected) => {
|
|
17
|
+
const summary = catalog.summaries.find(i => i.id === selected.id);
|
|
18
|
+
if (!summary)
|
|
19
|
+
return { selection: selected, item: null, missing: true };
|
|
20
|
+
const item = await catalog.load(summary.id);
|
|
21
|
+
return { selection: selected, item: { ...item, layer: summary.layer, revision: catalog.originalHashes.get(item.id) }, missing: false };
|
|
22
|
+
}));
|
|
23
|
+
return { root: selection.root, sessionId: selection.sessionId, task: selection.task, items, offset, total: omitted.length, nextOffset: offset + page.length < omitted.length ? offset + page.length : null, criticalOnly,
|
|
24
|
+
counts: { critical: selection.omitted.filter(i => i.priority === 'critical').length, normal: selection.omitted.filter(i => i.priority !== 'critical').length } };
|
|
25
|
+
}
|
|
26
|
+
// Offline optimization only removes exact duplicates. It uses the same structured
|
|
27
|
+
// action validator as assisted instruction policy consolidation, without inference.
|
|
28
|
+
const duplicateProvider = { name: 'deterministic-duplicates',
|
|
29
|
+
distill: async () => ({ output: { candidates: [] } }), reconcile: async () => { throw Error('Not used'); },
|
|
30
|
+
audit: async ({ items }) => {
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
for (const m of items) {
|
|
33
|
+
const key = JSON.stringify([applicabilityKey(m), m.contextPolicy, m.authority, m.priority, exactKnowledge(m.content)]);
|
|
34
|
+
groups.set(key, [...(groups.get(key) ?? []), m.id]);
|
|
35
|
+
}
|
|
36
|
+
const actions = [...groups.values()].flatMap(ids => ids.slice(1).map(id => ({ action: 'NO_CHANGE', sources: [ids[0], id], canonical: null, reason: 'Exact duplicate instruction with identical authority and scope.' })));
|
|
37
|
+
return { output: { status: actions.length ? 'changes_proposed' : 'clean', issues: [], actions } };
|
|
38
|
+
} };
|
|
39
|
+
export async function previewInstructionOptimization(selection, provider) {
|
|
40
|
+
return withSyncLock(selection.root, async () => {
|
|
41
|
+
await recoverSyncTransaction(selection.root);
|
|
42
|
+
const catalog = await loadMemoryCatalog(selection.root);
|
|
43
|
+
const shadowed = new Set(catalog.summaries.filter(i => ['active', 'disputed'].includes(i.status)).flatMap(i => i.supersedes));
|
|
44
|
+
const ids = [...new Set([...selection.omitted.map(i => i.id), ...selection.selectedIds])];
|
|
45
|
+
const eligible = ids.map(id => catalog.summaries.find(m => m.id === id)).filter(m => m?.category === 'instruction' && m.status === 'active' && !shadowed.has(m.id) && !m.relations.some(r => r.type === 'contradicts'));
|
|
46
|
+
// One bounded same-authority area per click, including loaded policies when useful.
|
|
47
|
+
const groups = new Map();
|
|
48
|
+
for (const m of eligible)
|
|
49
|
+
if (m) {
|
|
50
|
+
const key = JSON.stringify([m.layer, applicabilityKey(m), m.contextPolicy, m.authority, m.priority]);
|
|
51
|
+
groups.set(key, [...(groups.get(key) ?? []), m]);
|
|
52
|
+
}
|
|
53
|
+
const group = [...groups.values()].filter(g => g.length > 1).sort((a, b) => Number(b[0].priority === 'critical') - Number(a[0].priority === 'critical') || b.length - a.length)[0] ?? [];
|
|
54
|
+
const area = group.slice(0, INTELLIGENCE_LIMITS.auditItems);
|
|
55
|
+
const checks = await Promise.all(area.map(async (m) => ({ path: m.relativePath, hash: await fileHash(await safeProjectPath(selection.root, m.relativePath)) })));
|
|
56
|
+
const plan = await planAssistedSync(selection.root, [], provider ?? duplicateProvider, new Date(), new Set(), { instructionIds: area.map(m => m.id) });
|
|
57
|
+
const after = plan.writes.map(w => ({ ...parseKnowledge(w.after, w.path), path: w.path }));
|
|
58
|
+
const before = plan.knowledgeBefore;
|
|
59
|
+
const beforeTokens = before.reduce((n, m) => n + estimateTokens(knowledgeText(m)), 0);
|
|
60
|
+
const afterTokens = after.filter(m => m.status === 'active').reduce((n, m) => n + estimateTokens(knowledgeText(m)), 0);
|
|
61
|
+
if (plan.writes.length && (afterTokens > beforeTokens || after.filter(m => m.status === 'active').length >= before.length))
|
|
62
|
+
throw new Error('Optimization did not reduce instruction pressure. No changes were saved.');
|
|
63
|
+
return { root: selection.root, plan, checks, view: { before, after, beforeTokens, afterTokens, reducedBy: before.length - after.filter(m => m.status === 'active').length,
|
|
64
|
+
repository: after.some(m => m.path.startsWith('memory/')), mode: provider ? 'assisted' : 'deterministic',
|
|
65
|
+
notice: plan.writes.length ? 'Review the preserved clauses and scope before applying.' : provider ? 'No safe consolidation found in this bounded area. Edit obsolete or overly broad instructions individually.' : 'No exact duplicates found. Semantic policy consolidation requires MCP sampling support; individual editing remains available.',
|
|
66
|
+
inspected: area.length, remaining: Math.max(0, eligible.length - area.length) } };
|
|
67
|
+
}, { waitMs: 5000 });
|
|
68
|
+
}
|
|
69
|
+
export async function applyInstructionOptimization(preview) {
|
|
70
|
+
return withSyncLock(preview.root, async () => {
|
|
71
|
+
await recoverSyncTransaction(preview.root);
|
|
72
|
+
for (const check of preview.checks)
|
|
73
|
+
if (await fileHash(await safeProjectPath(preview.root, check.path)) !== check.hash)
|
|
74
|
+
throw new Error('Instructions changed. Optimize again before applying.');
|
|
75
|
+
const plan = preview.plan;
|
|
76
|
+
if (!plan.writes.length)
|
|
77
|
+
throw new Error('No consolidation changes to apply.');
|
|
78
|
+
const id = sortableId('chk');
|
|
79
|
+
const r = plan.result;
|
|
80
|
+
const checkpoint = { id, createdAt: new Date().toISOString(), previousCheckpoint: (await listCheckpoints(preview.root)).at(-1)?.id ?? null, sessions: [], consolidation: 'assisted-v1', intelligence: plan.intelligence, reasoning: plan.reasoning, knowledgeBefore: plan.knowledgeBefore, createdMemory: [], updatedMemory: r.updated, supersededMemory: r.superseded, duplicateMemory: r.duplicates, conflicts: r.conflicts, summary: { sessions: 0, candidates: 0, created: 0, consolidated: r.consolidated.length, duplicates: r.duplicates.length, superseded: 0, conflicts: 0 }, note: 'Explicit reviewed instruction optimization' };
|
|
81
|
+
await commitSyncTransaction(preview.root, [...plan.writes, { path: `checkpoints/${id}.json`, before: null, after: JSON.stringify(checkpoint, null, 2) + '\n' }], { policyMode: 'exact' });
|
|
82
|
+
await buildIndex(preview.root);
|
|
83
|
+
return { checkpoint: id, reducedBy: preview.view.reducedBy };
|
|
84
|
+
}, { waitMs: 5000 });
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=instruction-review.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { checkedPath, safeRelative, secretPath } from './plugin-files.js';
|
|
7
|
+
export const INTEGRITY_LIMITS = Object.freeze({ files: 20_000, bytes: 100_000_000, depth: 32, entries: 40_000, timeoutMs: 30_000 });
|
|
8
|
+
const skipped = new Set(['.git', 'node_modules', '.cache', '.transactions', 'dist', 'dist-ui']);
|
|
9
|
+
const privateStores = /(^|\/)\.prodero\/(?:local|sessions|checkpoints|\.cache|\.transactions|\.backups|\.migrations)(?:\/|$)/;
|
|
10
|
+
const canonicalStores = ['config.json', 'index.md', 'memory', 'skills', 'local/memory', 'local/skills', 'local/archive', 'archive', 'sessions', 'checkpoints'];
|
|
11
|
+
export async function integritySnapshot(root, relative, options = {}, canonical = false) {
|
|
12
|
+
const limits = { ...INTEGRITY_LIMITS, ...options.limits };
|
|
13
|
+
for (const key of Object.keys(INTEGRITY_LIMITS)) {
|
|
14
|
+
if (!Number.isInteger(limits[key]) || limits[key] < 1 || limits[key] > INTEGRITY_LIMITS[key])
|
|
15
|
+
throw new Error('Invalid integrity limit; limits may only be lowered');
|
|
16
|
+
}
|
|
17
|
+
const exclusions = new Set((options.exclude ?? []).map(safeRelative));
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const cancel = () => controller.abort();
|
|
20
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
21
|
+
if (options.signal?.aborted)
|
|
22
|
+
cancel();
|
|
23
|
+
const timer = setTimeout(cancel, limits.timeoutMs);
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
const check = () => { if (controller.signal.aborted || performance.now() - started >= limits.timeoutMs)
|
|
26
|
+
throw new Error('INTEGRITY_CANCELLED: digest cancelled or time budget exceeded; no complete digest produced'); };
|
|
27
|
+
const fail = (kind) => { throw new Error(`INTEGRITY_LIMIT: ${kind} budget exceeded; narrow the import tree or remove out-of-scope artifacts and retry. No complete digest produced.`); };
|
|
28
|
+
let entries = 0, bytes = 0, directories = 0, files = 0;
|
|
29
|
+
const digest = crypto.createHash('sha256').update(`curator-integrity-v2:${canonical ? 'canonical' : 'external'}\0`);
|
|
30
|
+
const hashes = Object.create(null);
|
|
31
|
+
const changed = () => new Error('Source tree changed while creating digest; retry audit');
|
|
32
|
+
const stable = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
33
|
+
async function git(args, input = '') {
|
|
34
|
+
check();
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn('git', ['-C', root, ...args], { stdio: ['pipe', 'pipe', 'pipe'], signal: controller.signal });
|
|
37
|
+
let output = '', errorText = '', error = false;
|
|
38
|
+
child.stdout.on('data', chunk => { output += chunk; if (Buffer.byteLength(output) > 8_000_000) {
|
|
39
|
+
error = true;
|
|
40
|
+
child.kill();
|
|
41
|
+
} });
|
|
42
|
+
child.stderr.on('data', chunk => { errorText = (errorText + chunk).slice(0, 4096); });
|
|
43
|
+
child.stdin.on('error', () => { });
|
|
44
|
+
child.on('error', () => reject(new Error('INTEGRITY_GIT: Git ignore inspection unavailable or cancelled; no complete digest produced')));
|
|
45
|
+
child.on('close', code => error ? reject(new Error('INTEGRITY_LIMIT: Git ignore output budget exceeded')) : resolve({ code, output, errorText }));
|
|
46
|
+
child.stdin.end(input);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
let gitRepository = false;
|
|
50
|
+
async function ignored(names) {
|
|
51
|
+
if (!gitRepository || !names.length)
|
|
52
|
+
return new Set();
|
|
53
|
+
if (Buffer.byteLength(names.join('\0')) > 4_000_000)
|
|
54
|
+
fail('Git path-input');
|
|
55
|
+
const result = await git(['check-ignore', '--no-index', '-z', '--stdin'], names.join('\0') + '\0');
|
|
56
|
+
if (result.code !== 0 && result.code !== 1)
|
|
57
|
+
throw new Error('INTEGRITY_GIT: Git ignore inspection failed; no complete digest produced');
|
|
58
|
+
return new Set(result.output.split('\0').filter(Boolean));
|
|
59
|
+
}
|
|
60
|
+
function excluded(rel, within) {
|
|
61
|
+
if (secretPath(rel))
|
|
62
|
+
return true;
|
|
63
|
+
if (canonical) {
|
|
64
|
+
if (rel.split('/').some(part => part.includes('.tmp-')))
|
|
65
|
+
return true;
|
|
66
|
+
if (rel === ".prodero")
|
|
67
|
+
return false;
|
|
68
|
+
const name = rel.slice('.prodero/'.length);
|
|
69
|
+
return !canonicalStores.some(store => name === store || name.startsWith(store + '/') || store.startsWith(name + '/'));
|
|
70
|
+
}
|
|
71
|
+
if (privateStores.test(rel) || rel.split('/').some(p => skipped.has(p)))
|
|
72
|
+
return true;
|
|
73
|
+
if (relative === '.' && rel.split('/')[0] === '.prodero')
|
|
74
|
+
return true;
|
|
75
|
+
return [...exclusions].some(p => within === p || within.startsWith(p + '/'));
|
|
76
|
+
}
|
|
77
|
+
async function hashFile(target, rel, stat) {
|
|
78
|
+
if (++files > limits.files)
|
|
79
|
+
fail('file-count');
|
|
80
|
+
if (stat.size > BigInt(limits.bytes - bytes))
|
|
81
|
+
fail('total-byte');
|
|
82
|
+
check();
|
|
83
|
+
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
84
|
+
try {
|
|
85
|
+
const before = await handle.stat({ bigint: true });
|
|
86
|
+
if (!before.isFile() || !stable(stat, before))
|
|
87
|
+
throw changed();
|
|
88
|
+
const value = crypto.createHash('sha256');
|
|
89
|
+
let read = 0;
|
|
90
|
+
for await (const chunk of handle.createReadStream({ autoClose: false, highWaterMark: 64 * 1024, signal: controller.signal })) {
|
|
91
|
+
check();
|
|
92
|
+
read += chunk.length;
|
|
93
|
+
if (read > limits.bytes - bytes)
|
|
94
|
+
fail('total-byte');
|
|
95
|
+
value.update(chunk);
|
|
96
|
+
}
|
|
97
|
+
const after = await handle.stat({ bigint: true });
|
|
98
|
+
const current = await fs.lstat(target, { bigint: true });
|
|
99
|
+
if (!stable(before, after) || !stable(after, current) || BigInt(read) !== before.size)
|
|
100
|
+
throw changed();
|
|
101
|
+
bytes += read;
|
|
102
|
+
hashes[rel] = value.digest('hex');
|
|
103
|
+
return { hash: hashes[rel], bytes: read };
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function visit(target, rel, within, depth) {
|
|
110
|
+
check();
|
|
111
|
+
if (excluded(rel, within))
|
|
112
|
+
return;
|
|
113
|
+
const before = await fs.lstat(target, { bigint: true });
|
|
114
|
+
if (before.isSymbolicLink())
|
|
115
|
+
throw new Error('Symlink in source tree rejected');
|
|
116
|
+
if (depth > limits.depth)
|
|
117
|
+
fail('traversal-depth');
|
|
118
|
+
if (before.isDirectory()) {
|
|
119
|
+
if (within) {
|
|
120
|
+
directories++;
|
|
121
|
+
digest.update(`D\0${within}\0`);
|
|
122
|
+
}
|
|
123
|
+
const names = [];
|
|
124
|
+
for await (const entry of await fs.opendir(target)) {
|
|
125
|
+
check();
|
|
126
|
+
if (++entries > limits.entries)
|
|
127
|
+
fail('entry-count');
|
|
128
|
+
const nextRel = rel === '.' ? entry.name : `${rel}/${entry.name}`;
|
|
129
|
+
const nextWithin = within ? `${within}/${entry.name}` : entry.name;
|
|
130
|
+
if (!excluded(nextRel, nextWithin))
|
|
131
|
+
names.push(entry.name);
|
|
132
|
+
}
|
|
133
|
+
// Batch Git checks and prune ignored directories before traversing them.
|
|
134
|
+
const paths = names.map(n => rel === '.' ? n : `${rel}/${n}`);
|
|
135
|
+
const ignores = await ignored(paths);
|
|
136
|
+
for (const name of names.sort()) {
|
|
137
|
+
const nextRel = rel === '.' ? name : `${rel}/${name}`;
|
|
138
|
+
if (!ignores.has(nextRel))
|
|
139
|
+
await visit(path.join(target, name), nextRel, within ? `${within}/${name}` : name, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
if (!stable(before, await fs.lstat(target, { bigint: true })))
|
|
142
|
+
throw changed();
|
|
143
|
+
}
|
|
144
|
+
else if (before.isFile()) {
|
|
145
|
+
const value = await hashFile(target, rel, before);
|
|
146
|
+
digest.update(`F\0${within}\0${value.bytes}\0${value.hash}\0`);
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
throw new Error('Special source file rejected');
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
check();
|
|
153
|
+
const base = await checkedPath(root, relative);
|
|
154
|
+
if (!canonical) {
|
|
155
|
+
const result = await git(['rev-parse', '--is-inside-work-tree']);
|
|
156
|
+
if (result.code !== 0 && !(result.code === 128 && /not a git repository/i.test(result.errorText)))
|
|
157
|
+
throw new Error('INTEGRITY_GIT: Unable to determine Git ignore scope');
|
|
158
|
+
gitRepository = result.code === 0 && result.output.trim() === 'true';
|
|
159
|
+
}
|
|
160
|
+
const stat = await fs.lstat(base).catch(error => { if (error.code === 'ENOENT')
|
|
161
|
+
return null; throw error; });
|
|
162
|
+
if (stat && !stat.isDirectory())
|
|
163
|
+
throw new Error('Source is not a directory');
|
|
164
|
+
if (stat && !(await ignored([relative])).has(relative))
|
|
165
|
+
await visit(base, relative, '', 0);
|
|
166
|
+
check();
|
|
167
|
+
return { hashes: Object.fromEntries(Object.entries(hashes)), digest: { version: 2, algorithm: 'sha256-tree-v2', sha256: digest.digest('hex'), files, directories, bytes } };
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=integrity-files.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { applicabilitySchema } from "./applicability.js";
|
|
2
|
+
import { assertNoSecrets } from "./secrets.js";
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { estimateTokens } from './telemetry.js';
|
|
5
|
+
/** Central limits are local estimates, never provider billing units. */
|
|
6
|
+
export const INTELLIGENCE_LIMITS = Object.freeze({
|
|
7
|
+
distillInputTokens: 4_000, candidatesPerBatch: 12, observationsPerBatch: 12,
|
|
8
|
+
relatedMemories: 12, reconcileInputTokens: 8_000,
|
|
9
|
+
auditItems: 16, auditInputTokens: 12_000, auditTriggerItems: 3,
|
|
10
|
+
outputTokens: 4_000, outputBytes: 32_000, timeoutMs: 45_000,
|
|
11
|
+
});
|
|
12
|
+
const text = z.string().trim().min(1);
|
|
13
|
+
const id = text.max(240);
|
|
14
|
+
export const canonicalProposalSchema = z.object({
|
|
15
|
+
applicability: applicabilitySchema.optional(),
|
|
16
|
+
title: text.max(200), content: text.max(8_000),
|
|
17
|
+
category: z.enum(['principle', 'invariant', 'decision', 'constraint', 'lesson', 'state', 'instruction']),
|
|
18
|
+
scope: z.enum(['global', 'module', 'task']), scopeRef: z.string().max(300).nullable(), scopeKind: z.enum(['path', 'topic']).optional(),
|
|
19
|
+
priority: z.enum(['critical', 'important', 'normal', 'contextual']),
|
|
20
|
+
}).strict();
|
|
21
|
+
export const candidateSchema = canonicalProposalSchema.extend({
|
|
22
|
+
id, kind: z.enum(['knowledge', 'skill_candidate', 'instruction_candidate']),
|
|
23
|
+
sources: z.array(id).min(1).max(24),
|
|
24
|
+
evidence: z.array(z.object({ source: id, quote: text.max(8_000) }).strict()).min(1).max(24),
|
|
25
|
+
supersedes: z.array(id).max(12),
|
|
26
|
+
}).strict();
|
|
27
|
+
export const distillSchema = z.object({ candidates: z.array(candidateSchema).max(INTELLIGENCE_LIMITS.candidatesPerBatch) }).strict();
|
|
28
|
+
export const reconciliationCategorySchema = z.enum(['state_transition', 'refinement', 'duplicate', 'supporting', 'potential_conflict', 'unresolved', 'independent']);
|
|
29
|
+
export const actionSchema = z.object({
|
|
30
|
+
action: z.enum(['CREATE', 'MERGE', 'SUPERSEDE', 'CONFLICT', 'NO_CHANGE', 'LINK', 'UNCERTAIN']),
|
|
31
|
+
sources: z.array(id).min(1).max(16),
|
|
32
|
+
canonical: canonicalProposalSchema.nullable(),
|
|
33
|
+
reason: text.max(500),
|
|
34
|
+
classification: reconciliationCategorySchema.optional(),
|
|
35
|
+
// Optional for backward-compatible providers; required for inferred replacement.
|
|
36
|
+
replacements: z.array(z.object({
|
|
37
|
+
target: id, previousQuote: text.max(8_000), replacementQuote: text.max(8_000),
|
|
38
|
+
transitionQuote: text.max(8_000), sameFact: z.literal(true), completeReplacement: z.literal(true),
|
|
39
|
+
basis: z.literal('explicit_transition'),
|
|
40
|
+
}).strict()).min(1).max(12).optional(),
|
|
41
|
+
}).strict();
|
|
42
|
+
export const reconcileSchema = z.object({ actions: z.array(actionSchema).min(1).max(1) }).strict();
|
|
43
|
+
export const auditSchema = z.object({
|
|
44
|
+
status: z.enum(['clean', 'changes_proposed', 'uncertain']),
|
|
45
|
+
issues: z.array(z.object({ kind: z.enum(['contradiction', 'duplicate', 'obsolete', 'unsafe_merge', 'fragmentation', 'constraint_loss', 'uncertain']), sources: z.array(id).min(1).max(16), reason: text.max(500) }).strict()).max(16),
|
|
46
|
+
actions: z.array(actionSchema).max(16),
|
|
47
|
+
}).strict();
|
|
48
|
+
export function intelligenceTelemetry(provider, fallbackReason) {
|
|
49
|
+
return { mode: provider ? 'assisted' : 'deterministic', provider: provider?.name ?? null, ...(fallbackReason ? { fallbackReason } : {}),
|
|
50
|
+
distillation: { calls: 0, sessions: 0, observations: 0, candidates: 0, inputTokens: 0, outputTokens: 0 },
|
|
51
|
+
reconciliation: { calls: 0, comparisons: 0, inputTokens: 0, outputTokens: 0, actions: { CREATE: 0, MERGE: 0, SUPERSEDE: 0, CONFLICT: 0, NO_CHANGE: 0, LINK: 0, UNCERTAIN: 0 } },
|
|
52
|
+
audit: { triggered: false, items: 0, truncated: false, issues: 0, proposed: 0, applied: 0, inputTokens: 0, outputTokens: 0, status: 'skipped' }, providerUsage: [] };
|
|
53
|
+
}
|
|
54
|
+
export class IntelligenceError extends Error {
|
|
55
|
+
phase;
|
|
56
|
+
constructor(phase, detail) {
|
|
57
|
+
super(`Knowledge engine ${phase} failed: ${detail}. No semantic plan was committed; sessions remain pending.`);
|
|
58
|
+
this.phase = phase;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function requestIntelligence(provider, phase, input, telemetry) {
|
|
62
|
+
assertNoSecrets(input);
|
|
63
|
+
const limit = phase === 'distill' ? INTELLIGENCE_LIMITS.distillInputTokens : phase === 'reconcile' ? INTELLIGENCE_LIMITS.reconcileInputTokens : INTELLIGENCE_LIMITS.auditInputTokens;
|
|
64
|
+
const inputTokens = estimateTokens(JSON.stringify(input));
|
|
65
|
+
if (inputTokens > limit)
|
|
66
|
+
throw new IntelligenceError(phase, 'input budget exceeded');
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
let timer;
|
|
69
|
+
try {
|
|
70
|
+
const response = await Promise.race([
|
|
71
|
+
provider[phase].call(provider, structuredClone(input), controller.signal),
|
|
72
|
+
new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('provider timed out')); }, INTELLIGENCE_LIMITS.timeoutMs); }),
|
|
73
|
+
]);
|
|
74
|
+
assertNoSecrets(response.output);
|
|
75
|
+
const encoded = JSON.stringify(response.output);
|
|
76
|
+
if (!encoded || Buffer.byteLength(encoded) > INTELLIGENCE_LIMITS.outputBytes)
|
|
77
|
+
throw new Error('output budget exceeded');
|
|
78
|
+
const output = (phase === 'distill' ? distillSchema : phase === 'reconcile' ? reconcileSchema : auditSchema).parse(response.output);
|
|
79
|
+
const target = phase === 'distill' ? telemetry.distillation : phase === 'reconcile' ? telemetry.reconciliation : telemetry.audit;
|
|
80
|
+
target.inputTokens += inputTokens;
|
|
81
|
+
target.outputTokens += estimateTokens(encoded);
|
|
82
|
+
if ('calls' in target)
|
|
83
|
+
target.calls++;
|
|
84
|
+
if (response.usage)
|
|
85
|
+
telemetry.providerUsage.push(response.usage);
|
|
86
|
+
return output;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new IntelligenceError(phase, error instanceof z.ZodError ? 'invalid structured output' : 'provider failed or returned unsafe content');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=intelligence.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Evidence-first proposals only. Classification never establishes authority. */
|
|
2
|
+
import { inferApplicability, buildTaskContext } from './applicability.js';
|
|
3
|
+
export const KNOWLEDGE_ANALYSIS_VERSION = 'evidence-3';
|
|
4
|
+
const transient = /\b(?:currently (?:fails?|blocked|broken)|temporary workaround|remains? blocked|planned work owns|discovery.*fails?)\b/i;
|
|
5
|
+
/** Keep exact text slices: no paraphrasing, dropped clauses or lexical authority. */
|
|
6
|
+
export function analyzeKnowledgeText(title, content) {
|
|
7
|
+
const boundaries = [...content.matchAll(/^(?:Decision|Impact|Constraint|Correction|Guard Pattern|Workflow Note|Test-Quality Note|State|Lesson|Principle|Invariant):\s*$/gmi)];
|
|
8
|
+
const chunks = boundaries.length > 1 ? boundaries.map((m, i) => ({ start: i === 0 ? 0 : m.index, end: boundaries[i + 1]?.index ?? content.length, label: m[0].trim().slice(0, -1).toLowerCase() })) : [{ start: 0, end: content.length, label: boundaries[0]?.[0].trim().slice(0, -1).toLowerCase() ?? '' }];
|
|
9
|
+
// Impact stays with its decision; split only lifecycle/category boundaries.
|
|
10
|
+
const groups = [];
|
|
11
|
+
for (const c of chunks) {
|
|
12
|
+
if ((c.label === 'impact' || c.label === 'constraint' && /\b(?:must|should|never|always|required|only|cannot|do not)\b/i.test(content.slice(groups.at(-1)?.start ?? 0, c.start))) && groups.length && !transient.test(content.slice(c.start, c.end)))
|
|
13
|
+
groups.at(-1).end = c.end;
|
|
14
|
+
else
|
|
15
|
+
groups.push({ ...c });
|
|
16
|
+
}
|
|
17
|
+
const wholeContext = buildTaskContext(`${title}\n${content}`);
|
|
18
|
+
const titleContext = buildTaskContext(title);
|
|
19
|
+
const domain = titleContext.subsystems.length ? titleContext.subsystems : wholeContext.subsystems;
|
|
20
|
+
return groups.filter(c => content.slice(c.start, c.end).trim()).map((c, i) => {
|
|
21
|
+
const body = content.slice(c.start, c.end).trim();
|
|
22
|
+
const historical = transient.test(body);
|
|
23
|
+
const normative = /\b(?:must|should|never|always|required|only|cannot|do not|prohibit)\b/i.test(body);
|
|
24
|
+
const category = historical ? 'state' : c.label === 'principle' ? 'principle' : c.label === 'invariant' ? 'invariant' : c.label === 'constraint' || c.label === 'guard pattern' ? 'constraint' : c.label === 'lesson' || c.label === 'correction' ? 'lesson' : c.label === 'decision' ? (normative ? 'instruction' : 'decision') : c.label === 'workflow note' || /^(?:Run |Use |Always |Never |Do not )/.test(body) ? 'instruction' : /\b(?:invariant|transactional invariant)\b/i.test(body) ? 'invariant' : /\b(?:must not|cannot|never persist|prohibit)\b/i.test(body) ? 'constraint' : /\b(?:uses?|chosen|canonical|decided|adopted)\b/i.test(body) ? 'decision' : 'state';
|
|
25
|
+
let applicability = inferApplicability(body);
|
|
26
|
+
// Named domain is stronger than incidental code identifiers. Preserve concrete paths
|
|
27
|
+
// when no domain is established; avoid AND-ing incidental paths with a subsystem.
|
|
28
|
+
// Titles are authored source metadata, not a built-in domain dictionary.
|
|
29
|
+
// A single explicit identifier may name the work boundary; ambiguous titles
|
|
30
|
+
// remain unresolved rather than inventing a preferred subsystem.
|
|
31
|
+
const named = [...new Set(domain.filter(n => !['AI', 'PHP', 'CLI', 'HTTP', 'HTML', 'JSON', 'DB', 'SQL'].includes(n)))];
|
|
32
|
+
if (named.length === 1)
|
|
33
|
+
applicability = { kind: 'scoped', subsystems: named, ...(/\b(?:tests?|testing|harness)\b/i.test(title) ? { taskKinds: ['testing'] } : {}) };
|
|
34
|
+
if (named.length > 1)
|
|
35
|
+
applicability = { kind: 'unresolved' };
|
|
36
|
+
const evidence = [`Source characters ${c.start}–${c.end}`, ...(c.label ? [`Explicit source field: ${c.label}`] : []), ...(historical ? ['Time-sensitive wording; retained as historical state'] : [])];
|
|
37
|
+
if (applicability.kind === 'scoped')
|
|
38
|
+
evidence.push(`Explicit work signals: ${JSON.stringify(applicability)}`);
|
|
39
|
+
return { title: groups.length === 1 ? title : `${title.slice(0, 155)} · ${c.label || `part ${i + 1}`}`, content: body, category, applicability, evidence, start: c.start, end: c.end, transient: historical };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export function importPreviewSummary(items) {
|
|
43
|
+
const selected = items.filter(i => i.include !== false);
|
|
44
|
+
return { records: selected.length, categories: Object.fromEntries([...new Set(selected.map(i => i.category))].sort().map(c => [c, selected.filter(i => i.category === c).length])), required: 0, relevant: selected.length, projectWide: selected.filter(i => i.applicability?.kind === 'project').length, scoped: selected.filter(i => i.applicability?.kind === 'scoped').length, unresolved: selected.filter(i => !i.applicability || i.applicability.kind === 'unresolved').length };
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=knowledge-analysis.js.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { sameApplicability, applicabilityKey, narrowsApplicability, applicabilityOf, withApplicability } from "./applicability.js";
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { knowledgePolicy } from './knowledge-policy.js';
|
|
5
|
+
import { evidenceSentences } from './semantic-safety.js';
|
|
6
|
+
const authorityContext = new AsyncLocalStorage();
|
|
7
|
+
const shape = (i) => JSON.stringify([i.category, i.scope, i.scopeRef, i.scopeKind, i.priority, i.content, applicabilityKey(i)]);
|
|
8
|
+
/** Internal application capability, not an MCP input or persisted permission.
|
|
9
|
+
* Entry points bind either host-attested intent or the explicit chat-rule command;
|
|
10
|
+
* the latter is model-mediated and retains distinct provenance.
|
|
11
|
+
* Scope is invocation-local; neither serialized evidence nor authority is a capability. */
|
|
12
|
+
export async function withTrustedKnowledgeIntent(root, proposed, proof, operation) {
|
|
13
|
+
if (!proof.evidence.trim())
|
|
14
|
+
throw new Error('Trusted policy intent requires an auditable source reference');
|
|
15
|
+
const grant = { active: true, root: path.resolve(root), proposed: structuredClone(proposed), evidence: proof.evidence, authority: proof.authority, before: proof.before ? JSON.stringify(proof.before) : undefined };
|
|
16
|
+
try {
|
|
17
|
+
return await authorityContext.run(grant, operation);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
grant.active = false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const protectedKnowledge = (i) => i.contextPolicy === 'required' || i.priority === 'critical' || ['explicit_user', 'reviewed'].includes(i.authority ?? '');
|
|
24
|
+
const sameScope = (a, b) => sameApplicability(a, b);
|
|
25
|
+
/** Conservative proof: whole clauses, including order, must survive exactly. No
|
|
26
|
+
* semantic equivalence is inferred from an LLM assertion or bag of words. */
|
|
27
|
+
export const sameProtection = (a, b) => a.category === b.category && sameScope(a, b) && a.priority === b.priority && a.contextPolicy === b.contextPolicy && a.authority === b.authority && JSON.stringify(evidenceSentences(a.content)) === JSON.stringify(evidenceSentences(b.content));
|
|
28
|
+
const samePolicyBoundary = (a, b) => a.category === b.category && sameScope(a, b) && a.priority === b.priority && a.contextPolicy === b.contextPolicy && a.authority === b.authority;
|
|
29
|
+
function clausesCovered(before, after) {
|
|
30
|
+
const output = evidenceSentences(after);
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
return evidenceSentences(before).every(clause => { const index = output.indexOf(clause, cursor); cursor = index + 1; return index >= 0; });
|
|
33
|
+
}
|
|
34
|
+
export const coversProtection = (before, after) => samePolicyBoundary(before, after) && clausesCovered(before.content, after.content);
|
|
35
|
+
function equivalentSources(proposed, sources) {
|
|
36
|
+
if (!sources.length || !sources.every(s => samePolicyBoundary(s, proposed)))
|
|
37
|
+
return false;
|
|
38
|
+
const clauses = new Set(sources.flatMap(s => evidenceSentences(s.content))), out = new Set(evidenceSentences(proposed.content));
|
|
39
|
+
return clauses.size === out.size && [...clauses].every(c => out.has(c)) && sources.every(s => clausesCovered(s.content, proposed.content));
|
|
40
|
+
}
|
|
41
|
+
export function inferredKnowledge(item, origin = 'inferred') {
|
|
42
|
+
return { ...item, ...knowledgePolicy({ ...item, version: item.version === 3 ? 3 : 2, contextPolicy: 'relevant', authority: origin }) };
|
|
43
|
+
}
|
|
44
|
+
/** The single semantic authorization boundary, shared by every canonical transaction. */
|
|
45
|
+
export function evaluateKnowledgeTransition(root, proposed, before, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
knowledgePolicy(proposed);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { decision: 'forbidden', item: before ?? proposed, reasons: ['invalid-policy-combination'], blocking: true };
|
|
51
|
+
}
|
|
52
|
+
if (reviewedBatchAllows(root, proposed, before))
|
|
53
|
+
return { decision: "allowed", item: proposed, reasons: ["trusted-current-operation"], blocking: false };
|
|
54
|
+
const grant = authorityContext.getStore();
|
|
55
|
+
if (grant?.active && grant.proposed.status === proposed.status && grant.root === path.resolve(root) && shape(grant.proposed) === shape(proposed) && (!grant.before || grant.before === JSON.stringify(before) || (!before && options.sources?.some(s => JSON.stringify(s) === grant.before)))) {
|
|
56
|
+
const item = { ...proposed, ...knowledgePolicy({ ...proposed, version: proposed.version === 3 ? 3 : 2, contextPolicy: grant.proposed.contextPolicy, authority: grant.authority }), evidence: [...(proposed.evidence ?? []), { kind: 'user_instruction', ref: grant.evidence }] };
|
|
57
|
+
return { decision: 'allowed', item, reasons: ['trusted-current-operation'], blocking: false };
|
|
58
|
+
}
|
|
59
|
+
if (!before) {
|
|
60
|
+
// Publication and equivalent replacement retain existing policy, never gain it.
|
|
61
|
+
const source = options.sources?.find(s => sameProtection(s, proposed));
|
|
62
|
+
if ((source && source.status === proposed.status) || (proposed.status === 'active' && equivalentSources(proposed, (options.sources ?? []).filter(s => s.status === 'active'))))
|
|
63
|
+
return { decision: 'allowed', item: proposed, reasons: ['existing-policy-preserved'], blocking: false };
|
|
64
|
+
const item = inferredKnowledge(proposed, proposed.authority === 'imported' ? 'imported' : 'inferred');
|
|
65
|
+
if (item.priority === 'critical')
|
|
66
|
+
item.priority = 'important';
|
|
67
|
+
const sources = options.sources ?? [];
|
|
68
|
+
// Never union incomparable source boundaries. A useful inferred fork can
|
|
69
|
+
// keep the first source boundary; uncovered originals remain active.
|
|
70
|
+
if (sources.length && !sources.some(s => sameScope(item, s))) {
|
|
71
|
+
item.scope = sources[0].scope;
|
|
72
|
+
item.scopeRef = sources[0].scopeRef;
|
|
73
|
+
item.scopeKind = sources[0].scopeKind;
|
|
74
|
+
Object.assign(item, withApplicability(item, applicabilityOf(sources[0])));
|
|
75
|
+
}
|
|
76
|
+
const changed = item.contextPolicy !== proposed.contextPolicy || item.authority !== proposed.authority || item.priority !== proposed.priority || !sameScope(item, proposed);
|
|
77
|
+
return { decision: changed ? (options.exact ? 'review_required' : 'allowed_with_safe_fallback') : 'allowed', item, reasons: changed ? ['unverified-promotion-saved-as-relevant'] : [], blocking: changed && !!options.exact };
|
|
78
|
+
}
|
|
79
|
+
const reasons = [];
|
|
80
|
+
if (options.replacementRequested && before.status !== proposed.status && !options.retirementCovered)
|
|
81
|
+
reasons.push("replacement-does-not-cover-source");
|
|
82
|
+
if (before.contextPolicy !== proposed.contextPolicy)
|
|
83
|
+
reasons.push('delivery-policy-change');
|
|
84
|
+
if (before.authority !== proposed.authority)
|
|
85
|
+
reasons.push('authority-change');
|
|
86
|
+
if (proposed.priority === 'critical' && before.priority !== 'critical')
|
|
87
|
+
reasons.push('criticality-increase');
|
|
88
|
+
if (protectedKnowledge(before)) {
|
|
89
|
+
if (!sameScope(before, proposed))
|
|
90
|
+
reasons.push('protected-applicability-change');
|
|
91
|
+
if (before.priority !== proposed.priority)
|
|
92
|
+
reasons.push('protected-priority-change');
|
|
93
|
+
if ((before.category !== proposed.category || JSON.stringify(evidenceSentences(before.content)) !== JSON.stringify(evidenceSentences(proposed.content))) && !equivalentSources(proposed, options.sources ?? [before]))
|
|
94
|
+
reasons.push('protected-meaning-change');
|
|
95
|
+
if ((proposed.relations ?? []).some(r => ['superseded_by', 'consolidated_into', 'contradicts'].includes(r.type) && !(before.relations ?? []).some(old => old.type === r.type && old.target === r.target)))
|
|
96
|
+
reasons.push('protected-topology-change');
|
|
97
|
+
if (before.status !== proposed.status && !options.retirementCovered)
|
|
98
|
+
reasons.push('protected-retirement');
|
|
99
|
+
}
|
|
100
|
+
// Ordinary inference may refine relevant knowledge but cannot broaden its scope.
|
|
101
|
+
if (!sameScope(before, proposed) && !(before.contextPolicy === 'relevant' && before.authority === 'inferred' && proposed.derivedFrom.length > 0 && narrowsApplicability(before, proposed)))
|
|
102
|
+
reasons.push('applicability-change');
|
|
103
|
+
if (!reasons.length)
|
|
104
|
+
return { decision: 'allowed', item: proposed, reasons: [], blocking: false };
|
|
105
|
+
return { decision: options.exact ? 'review_required' : 'review_recommended', item: structuredClone(before), reasons: [...new Set(reasons)], blocking: !!options.exact };
|
|
106
|
+
}
|
|
107
|
+
export class KnowledgePolicyReviewRequired extends Error {
|
|
108
|
+
transition;
|
|
109
|
+
code = 'POLICY_REVIEW_REQUIRED';
|
|
110
|
+
constructor(transition) {
|
|
111
|
+
super('POLICY_REVIEW_REQUIRED: The requested rule change needs trusted user intent or an exact policy review. Existing knowledge was preserved.');
|
|
112
|
+
this.transition = transition;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Optional diagnostics, not an approval queue and never a Prepare blocker. */
|
|
116
|
+
export function summarizePolicyDecisions(decisions) {
|
|
117
|
+
return { blocking: decisions.some(d => d.blocking), recommendations: decisions.filter(d => d.decision === 'review_recommended').length,
|
|
118
|
+
safeFallbacks: decisions.filter(d => d.decision === 'allowed_with_safe_fallback').length, reasons: [...new Set(decisions.flatMap(d => d.reasons))], interactivePrompts: 0 };
|
|
119
|
+
}
|
|
120
|
+
const batchReviewContext = new AsyncLocalStorage();
|
|
121
|
+
export async function withTrustedKnowledgeBatch(root, entries, evidence, operation) {
|
|
122
|
+
if (!evidence.trim() || !entries.length)
|
|
123
|
+
throw new Error('Exact trusted batch review required');
|
|
124
|
+
const grant = { active: true, root: path.resolve(root), entries: structuredClone(entries), evidence };
|
|
125
|
+
try {
|
|
126
|
+
return await batchReviewContext.run(grant, operation);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
grant.active = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function reviewedBatchAllows(root, proposed, before) {
|
|
133
|
+
const g = batchReviewContext.getStore();
|
|
134
|
+
// Canonical normalization eliminates presentation aliases, not semantic metadata.
|
|
135
|
+
const canonical = (i) => JSON.stringify(Object.fromEntries(Object.entries(i).filter(([k, v]) => v !== undefined && !['kind', 'enforcement'].includes(k)).sort(([a], [b]) => a.localeCompare(b))));
|
|
136
|
+
return !!g?.active && g.root === path.resolve(root) && g.entries.some(e => canonical(e.after) === canonical(proposed) && (!e.before ? !before : !!before && canonical(e.before) === canonical(before)));
|
|
137
|
+
}
|
|
138
|
+
/** Diagnostic reference only; never grants permission when replayed from history. */
|
|
139
|
+
export function trustedKnowledgeBatchEvidence(root) {
|
|
140
|
+
const grant = batchReviewContext.getStore();
|
|
141
|
+
return grant?.active && grant.root === path.resolve(root) ? grant.evidence : null;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=knowledge-authority.js.map
|