@sanqianx/project-knowledge 4.6.1
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +780 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +266 -0
- package/_site/README.md +30 -0
- package/_site/_test/ai-profile-resolver-test.js +137 -0
- package/_site/_test/ai-profile-test.js +75 -0
- package/_site/_test/ai-vendor-presets-test.js +91 -0
- package/_site/_test/automation-queue-test.js +14 -0
- package/_site/_test/automation-ui-test.js +40 -0
- package/_site/_test/background-task-registry-test.js +43 -0
- package/_site/_test/baseline-schema-test.js +91 -0
- package/_site/_test/bin-cli-test.js +218 -0
- package/_site/_test/bridge-adapter-test.js +95 -0
- package/_site/_test/bridge-consumer-service-test.js +310 -0
- package/_site/_test/cdp-browser-failure-test.js +100 -0
- package/_site/_test/chat-claudecodeui-match-test.js +92 -0
- package/_site/_test/claude-executable-discovery-test.js +90 -0
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -0
- package/_site/_test/claude-workbench-test.js +170 -0
- package/_site/_test/client-capture-restart-e2e-test.js +189 -0
- package/_site/_test/codex-conversation-projection-test.js +59 -0
- package/_site/_test/codex-notify-fanout-test.js +45 -0
- package/_site/_test/commit-boundary-freeze-test.js +147 -0
- package/_site/_test/commit-conversation-binding-test.js +99 -0
- package/_site/_test/commit-evidence-test.js +110 -0
- package/_site/_test/context-pack-test.js +198 -0
- package/_site/_test/conversation-api-test.js +119 -0
- package/_site/_test/conversation-store-test.js +136 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -0
- package/_site/_test/data-dir-migration-test.js +77 -0
- package/_site/_test/desktop-browser-compat-test.js +28 -0
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -0
- package/_site/_test/embedding-config-test.js +32 -0
- package/_site/_test/explicit-commit-processor-test.js +37 -0
- package/_site/_test/fixtures/fake-browser-exit.js +10 -0
- package/_site/_test/fixtures/fake-browser-hang.js +10 -0
- package/_site/_test/fixtures/make-git-repos.js +89 -0
- package/_site/_test/folder-picker-output-test.js +19 -0
- package/_site/_test/full-integration-e2e-test.js +165 -0
- package/_site/_test/git-validation-test.js +81 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -0
- package/_site/_test/hook-status-repair-api-test.js +152 -0
- package/_site/_test/hook-trigger-test.js +85 -0
- package/_site/_test/hook-worktree-test.js +42 -0
- package/_site/_test/import-preflight-api-test.js +233 -0
- package/_site/_test/import-ui-flow-test.js +171 -0
- package/_site/_test/index-builder-test.js +41 -0
- package/_site/_test/index-writer-concurrency-test.js +135 -0
- package/_site/_test/integration-adapters-test.js +243 -0
- package/_site/_test/integration-capture-setup-test.js +108 -0
- package/_site/_test/integration-surface-coverage-test.js +165 -0
- package/_site/_test/kb-framework-test.js +57 -0
- package/_site/_test/kbpath-follow-test.js +63 -0
- package/_site/_test/knowledge-db-test.js +144 -0
- package/_site/_test/knowledge-language-control-test.js +170 -0
- package/_site/_test/knowledge-maintenance-test.js +40 -0
- package/_site/_test/knowledge-migration-test.js +75 -0
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -0
- package/_site/_test/knowledge-query-test.js +54 -0
- package/_site/_test/knowledge-retrieval-service-test.js +77 -0
- package/_site/_test/knowledge-scopes-test.js +37 -0
- package/_site/_test/knowledge-storage-location-test.js +28 -0
- package/_site/_test/knowledge-storage-startup-test.js +52 -0
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -0
- package/_site/_test/legacy-claude-session-alias-test.js +106 -0
- package/_site/_test/legacy-forward-compat-test.js +257 -0
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -0
- package/_site/_test/legacy-routes-removed-test.js +47 -0
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -0
- package/_site/_test/log-redaction-test.js +58 -0
- package/_site/_test/logging-api-test.js +89 -0
- package/_site/_test/logging-sse-no-gap-test.js +88 -0
- package/_site/_test/logging-ui-test.js +104 -0
- package/_site/_test/markdown-delta-overlay-test.js +85 -0
- package/_site/_test/markdown-maintenance-api-test.js +75 -0
- package/_site/_test/markdown-maintenance-test.js +11 -0
- package/_site/_test/mcp-server-test.js +149 -0
- package/_site/_test/model-context-windows-test.js +89 -0
- package/_site/_test/module-bridge-eventbridge-test.js +193 -0
- package/_site/_test/non-release-ci-test.js +34 -0
- package/_site/_test/offline-boundary-isolation-test.js +32 -0
- package/_site/_test/orphaned-claim-recovery-test.js +80 -0
- package/_site/_test/p0-data-migration-characterization-test.js +37 -0
- package/_site/_test/p0-e2e-gate-test.js +347 -0
- package/_site/_test/package-boundary-test.js +42 -0
- package/_site/_test/package-startup-test.js +56 -0
- package/_site/_test/packaged-ui-smoke-test.js +79 -0
- package/_site/_test/path-consistency-test.js +151 -0
- package/_site/_test/pending-sweep-test.js +9 -0
- package/_site/_test/post-commit-automation-test.js +87 -0
- package/_site/_test/pr-consumer-contract-test.js +55 -0
- package/_site/_test/project-control-panel-task14-test.js +63 -0
- package/_site/_test/project-delete-recovery-test.js +64 -0
- package/_site/_test/project-goal-editor-test.js +145 -0
- package/_site/_test/project-layout-v2-migration-test.js +127 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -0
- package/_site/_test/project-store-test.js +90 -0
- package/_site/_test/prompt-settings-test.js +115 -0
- package/_site/_test/protected-architecture-gate-test.js +123 -0
- package/_site/_test/refactor-characterization-test.js +36 -0
- package/_site/_test/release-version-sync-test.js +98 -0
- package/_site/_test/requirement-binding-test.js +113 -0
- package/_site/_test/requirement-recorder-test.js +173 -0
- package/_site/_test/run-all-tests.js +156 -0
- package/_site/_test/runtime-endpoint-test.js +64 -0
- package/_site/_test/scanner-test.js +83 -0
- package/_site/_test/server-runtime-migration-safety-test.js +27 -0
- package/_site/_test/server-security-test.js +96 -0
- package/_site/_test/sessions-stream-test.js +92 -0
- package/_site/_test/shared-contracts-test.js +58 -0
- package/_site/_test/simple-import-test.js +97 -0
- package/_site/_test/startup-analysis-disabled-test.js +16 -0
- package/_site/_test/storage-foundation-test.js +78 -0
- package/_site/_test/structured-logger-test.js +101 -0
- package/_site/_test/task15-20-ui-flow-test.js +148 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -0
- package/_site/_test/team-knowledge-store-test.js +131 -0
- package/_site/_test/tracking-start-test.js +143 -0
- package/_site/_test/ui-i18n-toggle-test.js +114 -0
- package/_site/_test/ui-smoke-test.js +73 -0
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -0
- package/_site/_test/workbench-permission-test.js +118 -0
- package/_site/_test/workspace-ui-contract-test.js +43 -0
- package/_site/lib/ai-profile-resolver.js +78 -0
- package/_site/lib/ai-vendor-presets.js +83 -0
- package/_site/lib/ai-workspace.js +104 -0
- package/_site/lib/atomic-file.js +204 -0
- package/_site/lib/automation-config.js +140 -0
- package/_site/lib/bridge-adapter.js +256 -0
- package/_site/lib/bridge-consumer-service.js +402 -0
- package/_site/lib/claude-cli-runner.js +1599 -0
- package/_site/lib/commit-conversation-binder.js +246 -0
- package/_site/lib/commit-processing-ledger.js +27 -0
- package/_site/lib/commit-prompt.js +202 -0
- package/_site/lib/commit-reconciler.js +444 -0
- package/_site/lib/context-pack-builder.js +299 -0
- package/_site/lib/contracts.js +279 -0
- package/_site/lib/conversation-exclusions.js +204 -0
- package/_site/lib/conversation-query-service.js +161 -0
- package/_site/lib/conversation-store.js +385 -0
- package/_site/lib/data-dir.js +81 -0
- package/_site/lib/data-state-classifier.js +40 -0
- package/_site/lib/embedding-service.js +85 -0
- package/_site/lib/evidence-bundle.js +228 -0
- package/_site/lib/folder-picker-output.js +24 -0
- package/_site/lib/git-runner.js +62 -0
- package/_site/lib/github-team-store.js +1175 -0
- package/_site/lib/hook-manager.js +202 -0
- package/_site/lib/index-builder.js +157 -0
- package/_site/lib/index-service.js +173 -0
- package/_site/lib/integration-installer.js +907 -0
- package/_site/lib/kb-framework.js +191 -0
- package/_site/lib/kb-validator.js +124 -0
- package/_site/lib/knowledge-db.js +363 -0
- package/_site/lib/knowledge-promotion.js +435 -0
- package/_site/lib/knowledge-retrieval-service.js +401 -0
- package/_site/lib/knowledge-schema.js +121 -0
- package/_site/lib/knowledge-tool-runtime.js +359 -0
- package/_site/lib/legacy-data-manifest.js +26 -0
- package/_site/lib/llm-client.js +173 -0
- package/_site/lib/markdown-knowledge-indexer.js +230 -0
- package/_site/lib/migration-service.js +442 -0
- package/_site/lib/model-context-windows.js +82 -0
- package/_site/lib/module-bridge.js +496 -0
- package/_site/lib/post-commit-automation.js +141 -0
- package/_site/lib/project-lifecycle-service.js +511 -0
- package/_site/lib/project-registry-store.js +129 -0
- package/_site/lib/project-store.js +262 -0
- package/_site/lib/prompt-registry.js +148 -0
- package/_site/lib/requirement-adapters.js +10 -0
- package/_site/lib/requirement-binder.js +112 -0
- package/_site/lib/requirement-recorder.js +278 -0
- package/_site/lib/runtime-endpoint.js +160 -0
- package/_site/lib/scanner.js +298 -0
- package/_site/lib/server-app.js +1520 -0
- package/_site/lib/settings-store.js +113 -0
- package/_site/lib/storage-layout.js +162 -0
- package/_site/lib/structured-logger.js +574 -0
- package/_site/scripts/codex-notify-fanout.cjs +51 -0
- package/_site/scripts/folder-picker.ps1 +156 -0
- package/_site/scripts/hook-trigger.js +156 -0
- package/_site/scripts/sync-release-version.js +158 -0
- package/_site/server.js +12 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/bin/project-knowledge-integrations.js +241 -0
- package/bin/project-knowledge-kb.js +65 -0
- package/bin/project-knowledge-mcp.js +195 -0
- package/bin/project-knowledge.js +439 -0
- package/claude-prompts.json +21 -0
- package/docs/README.zh-CN.md +175 -0
- package/docs/assets/architecture.svg +182 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/draft-review.png +0 -0
- package/docs/assets/logo.svg +50 -0
- package/docs/assets/runs-drafts.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/project-registry-schema.md +33 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -0
- package/docs/testing-strategy.md +33 -0
- package/package.json +100 -0
- package/plugins/project-knowledge/.claude-plugin/plugin.json +19 -0
- package/plugins/project-knowledge/.codex-plugin/plugin.json +38 -0
- package/plugins/project-knowledge/.mcp.json +14 -0
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -0
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -0
- package/plugins/project-knowledge/skills/project-knowledge/agents/openai.yaml +11 -0
- package/templates/change.md +30 -0
- package/templates/project-readme.md +52 -0
- package/ui/app.css +58 -0
- package/ui/app.js +607 -0
- package/ui/favicon.svg +38 -0
- package/ui/i18n.js +146 -0
- package/ui/index.html +162 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const AtomicFile = require('./atomic-file');
|
|
5
|
+
const { DomainError, validateProjectId } = require('./contracts');
|
|
6
|
+
|
|
7
|
+
const HOOK_NAME = 'post-commit';
|
|
8
|
+
const HOOK_MARKER = '# PROJECT-KNOWLEDGE-HOOK ';
|
|
9
|
+
const LEGACY_HOOK_MARKER = '# KB-HOOK-MANAGED v1';
|
|
10
|
+
const MANAGED_VERSION = 2;
|
|
11
|
+
const HOOK_SCHEMA = 'project-knowledge/hook/v2';
|
|
12
|
+
|
|
13
|
+
function runGit(repoPath, args) {
|
|
14
|
+
const result = spawnSync('git', ['-C', repoPath, ...args], {
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
18
|
+
});
|
|
19
|
+
if (result.status !== 0) {
|
|
20
|
+
throw new DomainError('HOOK_INVALID', 'Git could not resolve the repository hook path.', {
|
|
21
|
+
status: 400,
|
|
22
|
+
details: { gitExitCode: result.status, gitError: String(result.stderr || '').trim().slice(0, 1000) },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return String(result.stdout || '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveGitHooksDir(_gitDir, repoPath) {
|
|
29
|
+
if (!repoPath) throw new DomainError('INVALID_ARGUMENT', 'repoPath is required.');
|
|
30
|
+
const absolute = path.resolve(repoPath);
|
|
31
|
+
const hooksPath = runGit(absolute, ['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
|
|
32
|
+
if (!hooksPath) throw new DomainError('HOOK_INVALID', 'Git returned an empty hooks path.', { status: 400 });
|
|
33
|
+
return path.resolve(absolute, hooksPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function repoGitDir(repoPath) {
|
|
37
|
+
try { return runGit(path.resolve(repoPath), ['rev-parse', '--path-format=absolute', '--git-dir']); }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shellQuote(value) { return `'${String(value).replace(/'/g, `'"'"'`)}'`; }
|
|
42
|
+
function toGitBashPath(value) { return path.resolve(value).replace(/\\/g, '/'); }
|
|
43
|
+
|
|
44
|
+
function markerFor(projectId) {
|
|
45
|
+
return `${HOOK_MARKER}${JSON.stringify({ schema: HOOK_SCHEMA, managedVersion: MANAGED_VERSION, projectId })}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseManagedMarker(text) {
|
|
49
|
+
const line = String(text || '').split(/\r?\n/).find(item => item.startsWith(HOOK_MARKER));
|
|
50
|
+
if (!line) return null;
|
|
51
|
+
try {
|
|
52
|
+
const marker = JSON.parse(line.slice(HOOK_MARKER.length));
|
|
53
|
+
if (marker.schema !== HOOK_SCHEMA || marker.managedVersion !== MANAGED_VERSION) return null;
|
|
54
|
+
validateProjectId(marker.projectId);
|
|
55
|
+
return marker;
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLegacyManagedHook(text) {
|
|
60
|
+
return String(text || '').split(/\r?\n/).some(line => line.startsWith(LEGACY_HOOK_MARKER));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildHookBody({ projectId, triggerScriptPath, nodeExecutable = process.execPath }) {
|
|
64
|
+
validateProjectId(projectId);
|
|
65
|
+
if (!triggerScriptPath) throw new DomainError('HOOK_INVALID', 'triggerScriptPath is required.', { status: 400 });
|
|
66
|
+
const trigger = path.resolve(triggerScriptPath);
|
|
67
|
+
if (!fs.existsSync(trigger) || !fs.statSync(trigger).isFile()) {
|
|
68
|
+
throw new DomainError('HOOK_INVALID', 'Hook trigger script does not exist.', { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
// T02 runtime contract: Git invokes the hook from a normal shell, which does
|
|
71
|
+
// NOT inherit ELECTRON_RUN_AS_NODE=1 the way the Desktop backend does.
|
|
72
|
+
// Without this prefix, the Electron executable (when used as nodeExecutable
|
|
73
|
+
// in a packaged Desktop install) would launch as a GUI product instead of
|
|
74
|
+
// executing the trigger as Node. Pure Node ignores the env var, so the
|
|
75
|
+
// CLI install path is unaffected.
|
|
76
|
+
return `#!/bin/sh
|
|
77
|
+
${markerFor(projectId)}
|
|
78
|
+
# Generated by project-knowledge. It captures a durable boundary before best-effort notification.
|
|
79
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
80
|
+
ELECTRON_RUN_AS_NODE=1 ${shellQuote(toGitBashPath(nodeExecutable))} ${shellQuote(toGitBashPath(trigger))} \\
|
|
81
|
+
--project-id ${shellQuote(projectId)} \\
|
|
82
|
+
--repo-root "$REPO_ROOT" >/dev/null 2>&1 || true
|
|
83
|
+
exit 0
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hookPathForRepo(repoPath) {
|
|
88
|
+
const absolute = path.resolve(repoPath);
|
|
89
|
+
const gitDir = repoGitDir(absolute);
|
|
90
|
+
if (!gitDir) throw new DomainError('HOOK_INVALID', 'The project is not a Git repository.', { status: 400 });
|
|
91
|
+
return path.join(resolveGitHooksDir(gitDir, absolute), HOOK_NAME);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function installHook(options = {}) {
|
|
95
|
+
const repoPath = path.resolve(String(options.repoPath || ''));
|
|
96
|
+
const projectId = validateProjectId(options.projectId || options.projectSlug || '');
|
|
97
|
+
const triggerScriptPath = options.triggerScriptPath || (options.siteRoot ? path.join(options.siteRoot, 'scripts', 'hook-trigger.js') : '');
|
|
98
|
+
const body = buildHookBody({ projectId, triggerScriptPath, nodeExecutable: options.nodeExecutable });
|
|
99
|
+
const hookPath = hookPathForRepo(repoPath);
|
|
100
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
101
|
+
if (fs.existsSync(hookPath)) {
|
|
102
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
103
|
+
const marker = parseManagedMarker(existing);
|
|
104
|
+
if (!marker) {
|
|
105
|
+
if (!isLegacyManagedHook(existing)) {
|
|
106
|
+
throw new DomainError('HOOK_CONFLICT', 'A non-managed post-commit hook already exists.', { status: 409 });
|
|
107
|
+
}
|
|
108
|
+
// Legacy v1 managed hook: preflight already classifies it as an
|
|
109
|
+
// in-place upgrade target ("legacy-v1"), so overwrite below instead of
|
|
110
|
+
// refusing — otherwise import promises a migration the installer
|
|
111
|
+
// rejects for every repo hooked by an older KB generation.
|
|
112
|
+
}
|
|
113
|
+
if (marker && marker.projectId !== projectId) {
|
|
114
|
+
throw new DomainError('HOOK_CONFLICT', 'The managed hook belongs to another project.', { status: 409 });
|
|
115
|
+
}
|
|
116
|
+
if (existing === body) return { ok: true, installed: true, updated: false, hookPath, projectId, managedVersion: MANAGED_VERSION };
|
|
117
|
+
}
|
|
118
|
+
AtomicFile.writeFileAtomic(hookPath, body, { mode: 0o755 });
|
|
119
|
+
try { fs.chmodSync(hookPath, 0o755); } catch {
|
|
120
|
+
// Windows does not expose POSIX executable bits; the atomic write used mode 0755 where supported.
|
|
121
|
+
}
|
|
122
|
+
const readback = fs.readFileSync(hookPath, 'utf8');
|
|
123
|
+
const marker = parseManagedMarker(readback);
|
|
124
|
+
if (!marker || marker.projectId !== projectId || !readback.includes(toGitBashPath(triggerScriptPath))) {
|
|
125
|
+
try { fs.unlinkSync(hookPath); } catch {
|
|
126
|
+
// Verification failure remains authoritative; the lifecycle journal records rollback residue.
|
|
127
|
+
}
|
|
128
|
+
throw new DomainError('HOOK_INVALID', 'Managed hook verification failed.', { status: 500 });
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, installed: true, updated: true, hookPath, projectId, managedVersion: MANAGED_VERSION };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function uninstallHook({ repoPath, projectId = '' } = {}) {
|
|
134
|
+
const absolute = path.resolve(String(repoPath || ''));
|
|
135
|
+
const hookPath = hookPathForRepo(absolute);
|
|
136
|
+
if (!fs.existsSync(hookPath)) return { ok: true, removed: false, hookPath };
|
|
137
|
+
const text = fs.readFileSync(hookPath, 'utf8');
|
|
138
|
+
const marker = parseManagedMarker(text);
|
|
139
|
+
if (!marker) throw new DomainError('HOOK_CONFLICT', 'The post-commit hook is not managed by project-knowledge.', { status: 409 });
|
|
140
|
+
if (projectId && marker.projectId !== validateProjectId(projectId)) throw new DomainError('HOOK_CONFLICT', 'The managed hook belongs to another project.', { status: 409 });
|
|
141
|
+
fs.unlinkSync(hookPath);
|
|
142
|
+
return { ok: true, removed: true, hookPath, projectId: marker.projectId };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readHookStatus({ repoPath, projectId = '' } = {}) {
|
|
146
|
+
const absolute = path.resolve(String(repoPath || ''));
|
|
147
|
+
let hookPath;
|
|
148
|
+
try { hookPath = hookPathForRepo(absolute); }
|
|
149
|
+
catch (error) { return { ok: true, installed: false, repoPath: absolute, reason: error.message }; }
|
|
150
|
+
if (!fs.existsSync(hookPath)) return { ok: true, installed: false, hookPath, repoPath: absolute };
|
|
151
|
+
const text = fs.readFileSync(hookPath, 'utf8');
|
|
152
|
+
const marker = parseManagedMarker(text);
|
|
153
|
+
if (!marker) return { ok: true, installed: false, kbManaged: false, hookPath, repoPath: absolute, reason: isLegacyManagedHook(text) ? 'legacy managed hook requires migration' : 'third-party hook' };
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
installed: !projectId || marker.projectId === projectId,
|
|
157
|
+
kbManaged: true,
|
|
158
|
+
hookPath,
|
|
159
|
+
repoPath: absolute,
|
|
160
|
+
projectId: marker.projectId,
|
|
161
|
+
managedVersion: marker.managedVersion,
|
|
162
|
+
bytes: text.length,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function migrateManagedHook(options = {}) {
|
|
167
|
+
const hookPath = hookPathForRepo(options.repoPath);
|
|
168
|
+
if (!fs.existsSync(hookPath)) return { ok: true, migrated: false, reason: 'missing' };
|
|
169
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
170
|
+
const current = parseManagedMarker(existing);
|
|
171
|
+
if (current) return { ok: true, migrated: false, reason: 'current', hookPath, managedVersion: current.managedVersion };
|
|
172
|
+
if (!isLegacyManagedHook(existing)) throw new DomainError('HOOK_CONFLICT', 'A third-party post-commit hook cannot be migrated.', { status: 409 });
|
|
173
|
+
const result = installLegacyReplacement(options, hookPath);
|
|
174
|
+
return { ...result, migrated: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function installLegacyReplacement(options, hookPath) {
|
|
178
|
+
const body = buildHookBody(options);
|
|
179
|
+
AtomicFile.writeFileAtomic(hookPath, body, { mode: 0o755 });
|
|
180
|
+
try { fs.chmodSync(hookPath, 0o755); } catch {
|
|
181
|
+
// Windows does not expose POSIX executable bits; the atomic write used mode 0755 where supported.
|
|
182
|
+
}
|
|
183
|
+
const marker = parseManagedMarker(fs.readFileSync(hookPath, 'utf8'));
|
|
184
|
+
if (!marker) throw new DomainError('HOOK_INVALID', 'Migrated hook verification failed.', { status: 500 });
|
|
185
|
+
return { ok: true, installed: true, updated: true, hookPath, projectId: marker.projectId, managedVersion: marker.managedVersion };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
HOOK_NAME,
|
|
190
|
+
HOOK_MARKER,
|
|
191
|
+
LEGACY_HOOK_MARKER,
|
|
192
|
+
MANAGED_VERSION,
|
|
193
|
+
HOOK_SCHEMA,
|
|
194
|
+
resolveGitHooksDir,
|
|
195
|
+
repoGitDir,
|
|
196
|
+
parseManagedMarker,
|
|
197
|
+
buildHookBody,
|
|
198
|
+
installHook,
|
|
199
|
+
uninstallHook,
|
|
200
|
+
readHookStatus,
|
|
201
|
+
migrateManagedHook,
|
|
202
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const MAX_INDEX_ITEMS = 40;
|
|
5
|
+
const MAX_MODULE_ITEMS = 80;
|
|
6
|
+
const MAX_TAGS = 4;
|
|
7
|
+
const MAX_SOURCE_PATHS = 3;
|
|
8
|
+
const MAX_LOOKUPS = 4;
|
|
9
|
+
const MAX_CELL_CHARS = 120;
|
|
10
|
+
const GENERATED_NOTICE = '<!-- Generated by project-knowledge. Do not edit manually. -->';
|
|
11
|
+
|
|
12
|
+
function parseFrontmatter(text) {
|
|
13
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(String(text || ''));
|
|
14
|
+
if (!match) return {};
|
|
15
|
+
const meta = {};
|
|
16
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
17
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim());
|
|
18
|
+
if (!m) continue;
|
|
19
|
+
let value = m[2].trim();
|
|
20
|
+
if (/^\[.*\]$/.test(value)) {
|
|
21
|
+
value = value.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
meta[m[1]] = value;
|
|
24
|
+
}
|
|
25
|
+
return meta;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function listMarkdown(dir) {
|
|
29
|
+
if (!fs.existsSync(dir)) return [];
|
|
30
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '00-index.md')
|
|
32
|
+
.map(entry => {
|
|
33
|
+
const rel = entry.name;
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
const text = fs.readFileSync(full, 'utf-8');
|
|
36
|
+
const meta = parseFrontmatter(text);
|
|
37
|
+
const title = meta.title || text.match(/^#\s+(.+)$/m)?.[1] || entry.name.replace(/\.md$/, '');
|
|
38
|
+
return { rel, full, meta, title, updatedAt: meta.updatedAt || meta.period || '' };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function metaList(value) {
|
|
43
|
+
if (Array.isArray(value)) return value.filter(Boolean);
|
|
44
|
+
if (!value) return [];
|
|
45
|
+
return String(value).split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function compactList(value, limit) {
|
|
49
|
+
const items = metaList(value);
|
|
50
|
+
if (items.length <= limit) return items.join(', ');
|
|
51
|
+
return `${items.slice(0, limit).join(', ')} (+${items.length - limit})`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cell(value, maxChars = MAX_CELL_CHARS) {
|
|
55
|
+
const text = Array.isArray(value) ? value.join(', ') : String(value || '');
|
|
56
|
+
const clean = text.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').trim();
|
|
57
|
+
if (!clean) return '-';
|
|
58
|
+
return clean.length > maxChars ? `${clean.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…` : clean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function indexSummary(total, shown) {
|
|
62
|
+
return shown < total
|
|
63
|
+
? `Items: ${total} (showing ${shown}; use knowledge search or file search for older entries)`
|
|
64
|
+
: `Items: ${total}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function markdownLink(title, rel) {
|
|
68
|
+
const label = cell(title, 80).replace(/\[/g, '\\[').replace(/\]/g, '\\]');
|
|
69
|
+
const target = encodeURI(String(rel || '')).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
70
|
+
return `[${label}](./${target})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderModuleIndex(kbPath) {
|
|
74
|
+
const dir = path.join(kbPath, 'modules');
|
|
75
|
+
const items = listMarkdown(dir).sort((a, b) => a.title.localeCompare(b.title));
|
|
76
|
+
const visible = items.slice(0, MAX_MODULE_ITEMS);
|
|
77
|
+
const lines = [
|
|
78
|
+
GENERATED_NOTICE,
|
|
79
|
+
'',
|
|
80
|
+
'# Modules Index',
|
|
81
|
+
'',
|
|
82
|
+
indexSummary(items.length, visible.length),
|
|
83
|
+
'',
|
|
84
|
+
'| Module | Tags | Source Paths | Routes / Symbols | Updated |',
|
|
85
|
+
'|---|---|---|---|---|',
|
|
86
|
+
];
|
|
87
|
+
for (const item of visible) {
|
|
88
|
+
const tagsText = compactList(item.meta.tags, MAX_TAGS);
|
|
89
|
+
const sourcePaths = compactList(item.meta.sourcePaths, MAX_SOURCE_PATHS);
|
|
90
|
+
const routes = metaList(item.meta.routes);
|
|
91
|
+
const symbols = metaList(item.meta.symbols);
|
|
92
|
+
const lookup = compactList([...routes, ...symbols], MAX_LOOKUPS);
|
|
93
|
+
lines.push(`| ${markdownLink(item.title, item.rel)} | ${cell(tagsText)} | ${cell(sourcePaths)} | ${cell(lookup)} | ${cell(item.updatedAt)} |`);
|
|
94
|
+
}
|
|
95
|
+
if (!items.length) lines.push('| No modules yet | - | - | - | - |');
|
|
96
|
+
return { path: 'modules/00-index.md', count: items.length, content: lines.join('\n') + '\n' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeModuleIndex(kbPath) {
|
|
100
|
+
const rendered = renderModuleIndex(kbPath);
|
|
101
|
+
const dest = path.join(kbPath, rendered.path);
|
|
102
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
103
|
+
fs.writeFileSync(dest, rendered.content, 'utf-8');
|
|
104
|
+
return { path: rendered.path, count: rendered.count };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderChangesIndex(kbPath) {
|
|
108
|
+
const dir = path.join(kbPath, 'changes');
|
|
109
|
+
const items = listMarkdown(dir).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)) || a.title.localeCompare(b.title));
|
|
110
|
+
const visible = items.slice(0, MAX_INDEX_ITEMS);
|
|
111
|
+
const lines = [
|
|
112
|
+
GENERATED_NOTICE,
|
|
113
|
+
'',
|
|
114
|
+
'# Changes Index',
|
|
115
|
+
'',
|
|
116
|
+
indexSummary(items.length, visible.length),
|
|
117
|
+
'',
|
|
118
|
+
'| Change | Tags | Affected Modules | Period / Updated | Intent |',
|
|
119
|
+
'|---|---|---|---|---|',
|
|
120
|
+
];
|
|
121
|
+
for (const item of visible) {
|
|
122
|
+
const tagsText = compactList(item.meta.tags, MAX_TAGS);
|
|
123
|
+
const affected = compactList(item.meta.affectedModules, MAX_SOURCE_PATHS);
|
|
124
|
+
const intent = item.meta.developmentIntent || item.meta.intent || '';
|
|
125
|
+
lines.push(`| ${markdownLink(item.title, item.rel)} | ${cell(tagsText)} | ${cell(affected)} | ${cell(item.updatedAt)} | ${cell(intent)} |`);
|
|
126
|
+
}
|
|
127
|
+
if (!items.length) lines.push('| No changes yet | - | - | - | - |');
|
|
128
|
+
return { path: 'changes/00-index.md', count: items.length, content: lines.join('\n') + '\n' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeChangesIndex(kbPath) {
|
|
132
|
+
const rendered = renderChangesIndex(kbPath);
|
|
133
|
+
const dest = path.join(kbPath, rendered.path);
|
|
134
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
135
|
+
fs.writeFileSync(dest, rendered.content, 'utf-8');
|
|
136
|
+
return { path: rendered.path, count: rendered.count };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function regenerateIndexes(kbPath) {
|
|
140
|
+
return {
|
|
141
|
+
modules: writeModuleIndex(kbPath),
|
|
142
|
+
changes: writeChangesIndex(kbPath),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
parseFrontmatter,
|
|
148
|
+
compactList,
|
|
149
|
+
cell,
|
|
150
|
+
markdownLink,
|
|
151
|
+
GENERATED_NOTICE,
|
|
152
|
+
renderModuleIndex,
|
|
153
|
+
renderChangesIndex,
|
|
154
|
+
regenerateIndexes,
|
|
155
|
+
writeModuleIndex,
|
|
156
|
+
writeChangesIndex,
|
|
157
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const AtomicFile = require('./atomic-file');
|
|
4
|
+
const { DomainError, createId, validateProjectId } = require('./contracts');
|
|
5
|
+
const { StorageLayout } = require('./storage-layout');
|
|
6
|
+
const { ProjectRegistryStore } = require('./project-registry-store');
|
|
7
|
+
const { ProjectStore } = require('./project-store');
|
|
8
|
+
const { CHUNKER_VERSION, listMarkdownFiles } = require('./markdown-knowledge-indexer');
|
|
9
|
+
const { sha256 } = require('./knowledge-schema');
|
|
10
|
+
|
|
11
|
+
let globalWriterTail = Promise.resolve();
|
|
12
|
+
|
|
13
|
+
function buildSourceManifest(projectId, knowledgePath, generation, sourceCommit = '') {
|
|
14
|
+
const root = path.resolve(knowledgePath);
|
|
15
|
+
const entries = {};
|
|
16
|
+
for (const filePath of listMarkdownFiles(root)) {
|
|
17
|
+
const entryId = path.relative(root, filePath).replace(/\\/g, '/');
|
|
18
|
+
const markdown = fs.readFileSync(filePath, 'utf8');
|
|
19
|
+
entries[entryId] = {
|
|
20
|
+
entryId,
|
|
21
|
+
documentHash: sha256(`chunker:${CHUNKER_VERSION}\n${markdown}`),
|
|
22
|
+
sourceCommit: sourceCommit || '',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
schema: 'knowledge-index-source-manifest/v1',
|
|
27
|
+
projectId,
|
|
28
|
+
generation: Number(generation || 0),
|
|
29
|
+
sourceCommit: sourceCommit || null,
|
|
30
|
+
entries,
|
|
31
|
+
entryCount: Object.keys(entries).length,
|
|
32
|
+
updatedAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function enqueueGlobal(task) {
|
|
37
|
+
const run = globalWriterTail.catch(() => {}).then(task);
|
|
38
|
+
globalWriterTail = run.catch(() => {});
|
|
39
|
+
return run;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class IndexService {
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.layout = options.layout || new StorageLayout(options);
|
|
45
|
+
this.registryStore = options.registryStore || new ProjectRegistryStore({ layout: this.layout });
|
|
46
|
+
this.projectStore = options.projectStore || new ProjectStore({ layout: this.layout });
|
|
47
|
+
this.adapter = options.adapter || null;
|
|
48
|
+
this.atomic = options.atomic || AtomicFile;
|
|
49
|
+
this.logger = options.logger || null;
|
|
50
|
+
this.pending = new Map();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async log(level, event, message, context) {
|
|
54
|
+
if (this.logger && typeof this.logger[level] === 'function') await this.logger[level](event, message, context);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
enqueue(projectId) {
|
|
58
|
+
validateProjectId(projectId);
|
|
59
|
+
const existing = this.pending.get(projectId);
|
|
60
|
+
if (existing) {
|
|
61
|
+
existing.rerun = true;
|
|
62
|
+
return existing.promise;
|
|
63
|
+
}
|
|
64
|
+
const entry = { rerun: false, promise: null };
|
|
65
|
+
entry.promise = enqueueGlobal(() => this.processEntry(projectId, entry));
|
|
66
|
+
this.pending.set(projectId, entry);
|
|
67
|
+
entry.promise.finally(() => {
|
|
68
|
+
if (this.pending.get(projectId) === entry) this.pending.delete(projectId);
|
|
69
|
+
});
|
|
70
|
+
return entry.promise;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async processEntry(projectId, entry) {
|
|
74
|
+
let lastResult = { ok: true, skipped: true };
|
|
75
|
+
do {
|
|
76
|
+
entry.rerun = false;
|
|
77
|
+
lastResult = await this.processProject(projectId);
|
|
78
|
+
if (!lastResult.ok) return lastResult;
|
|
79
|
+
const state = this.projectStore.readState(projectId);
|
|
80
|
+
if (state.index.dirty) entry.rerun = true;
|
|
81
|
+
} while (entry.rerun);
|
|
82
|
+
return lastResult;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async processProject(projectId) {
|
|
86
|
+
const config = this.projectStore.readConfig(projectId);
|
|
87
|
+
const before = this.projectStore.readState(projectId);
|
|
88
|
+
if (!before.index.dirty) return { ok: true, skipped: true, projectId };
|
|
89
|
+
const generation = before.index.generation;
|
|
90
|
+
const operationId = createId('op');
|
|
91
|
+
try {
|
|
92
|
+
if (!this.adapter || typeof this.adapter.indexProject !== 'function') throw new DomainError('INVALID_ARGUMENT', 'Index adapter is unavailable.', { status: 503, retryable: true });
|
|
93
|
+
const result = await this.adapter.indexProject({
|
|
94
|
+
projectId,
|
|
95
|
+
config,
|
|
96
|
+
knowledgePath: config.knowledgePath,
|
|
97
|
+
indexPath: this.layout.getIndexPath(),
|
|
98
|
+
generation,
|
|
99
|
+
sinceCommit: before.index.sinceCommit,
|
|
100
|
+
});
|
|
101
|
+
const sourceManifest = buildSourceManifest(projectId, config.knowledgePath, generation, before.index.sinceCommit);
|
|
102
|
+
this.atomic.writeJsonAtomic(this.layout.getRuntimePath('index-sources', `${projectId}.json`), sourceManifest);
|
|
103
|
+
await this.projectStore.updateState(projectId, state => {
|
|
104
|
+
if (state.index.generation === generation) {
|
|
105
|
+
state.index.dirty = false;
|
|
106
|
+
state.index.sinceCommit = null;
|
|
107
|
+
state.index.lastError = null;
|
|
108
|
+
state.index.lastIndexedAt = new Date().toISOString();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await this.log('info', 'index.project_applied', 'Derived knowledge index updated.', { projectId, operationId, generation, phase: 'index.applied' });
|
|
112
|
+
return { ok: true, projectId, generation, result: result || null };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await this.projectStore.updateState(projectId, state => {
|
|
115
|
+
state.index.dirty = true;
|
|
116
|
+
state.index.lastError = {
|
|
117
|
+
code: error.code || 'INVALID_ARGUMENT',
|
|
118
|
+
message: String(error.message || 'Index update failed.'),
|
|
119
|
+
generation,
|
|
120
|
+
ts: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
await this.log('error', 'index.project_failed', 'Derived index update failed; Markdown remains authoritative.', { projectId, operationId, generation, phase: 'index.queued', error });
|
|
124
|
+
return { ok: false, projectId, generation, error: { code: error.code || 'INVALID_ARGUMENT', message: error.message } };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
retryDirtyProjects() {
|
|
129
|
+
return Promise.all(this.registryStore.listIds()
|
|
130
|
+
.filter(projectId => this.projectStore.readState(projectId).index.dirty)
|
|
131
|
+
.map(projectId => this.enqueue(projectId)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async fullRebuild(options = {}) {
|
|
135
|
+
if (!this.adapter || typeof this.adapter.buildFull !== 'function' || typeof this.adapter.validateIndex !== 'function') {
|
|
136
|
+
throw new DomainError('INVALID_ARGUMENT', 'Index adapter does not support atomic full rebuild.', { status: 503 });
|
|
137
|
+
}
|
|
138
|
+
return enqueueGlobal(async () => {
|
|
139
|
+
const operationId = options.operationId || createId('op');
|
|
140
|
+
const target = this.layout.getIndexPath();
|
|
141
|
+
const parent = path.dirname(target);
|
|
142
|
+
const temp = path.join(parent, `.knowledge.rebuild.${operationId}`);
|
|
143
|
+
const backup = this.layout.getRecoveryPath('index', `${operationId}.previous`);
|
|
144
|
+
if (fs.existsSync(temp)) throw new DomainError('MIGRATION_TARGET_CONFLICT', 'Index rebuild staging already exists.', { status: 409 });
|
|
145
|
+
const projects = this.registryStore.listIds().map(projectId => ({ projectId, config: this.projectStore.readConfig(projectId) }));
|
|
146
|
+
try {
|
|
147
|
+
await this.adapter.buildFull({ targetPath: temp, projects });
|
|
148
|
+
const validation = await this.adapter.validateIndex(temp);
|
|
149
|
+
if (!validation || validation.ok !== true) throw new DomainError('DATA_CORRUPT', 'Rebuilt index validation failed.', { status: 500 });
|
|
150
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
151
|
+
if (fs.existsSync(target)) fs.renameSync(target, backup);
|
|
152
|
+
try { fs.renameSync(temp, target); }
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (fs.existsSync(backup) && !fs.existsSync(target)) fs.renameSync(backup, target);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
for (const { projectId, config } of projects) {
|
|
158
|
+
const state = this.projectStore.readState(projectId);
|
|
159
|
+
const sourceManifest = buildSourceManifest(projectId, config.knowledgePath, state.index.generation, state.index.sinceCommit);
|
|
160
|
+
this.atomic.writeJsonAtomic(this.layout.getRuntimePath('index-sources', `${projectId}.json`), sourceManifest);
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, operationId, target, backup: fs.existsSync(backup) ? backup : null, validation };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (fs.existsSync(temp)) fs.rmSync(temp, { recursive: true, force: true });
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static async flush() { await globalWriterTail.catch(() => {}); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { IndexService, enqueueGlobal, buildSourceManifest };
|