project-atlas 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/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +57 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/SECURITY.md +28 -0
- package/adapters/claude-code/README.md +27 -0
- package/adapters/continue/README.md +16 -0
- package/adapters/cursor/README.md +29 -0
- package/adapters/opencode/README.md +37 -0
- package/adapters/opencode/commands/kb-context.md +5 -0
- package/adapters/opencode/commands/kb-refresh.md +11 -0
- package/adapters/opencode/skills/project-atlas/SKILL.md +15 -0
- package/adapters/opencode/tools/project_atlas_context.js +37 -0
- package/adapters/opencode/tools/project_atlas_propose.js +53 -0
- package/adapters/opencode/tools/project_atlas_scan.js +34 -0
- package/dist/core.js +1395 -0
- package/dist/frontmatter.js +103 -0
- package/dist/index.js +7 -0
- package/dist/mcp.js +172 -0
- package/dist/scanner.js +128 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +174 -0
- package/docs/site/README.md +47 -0
- package/docs/site/agent-quickstart.md +243 -0
- package/docs/site/best-practices.md +87 -0
- package/docs/site/en/README.md +49 -0
- package/docs/site/en/agent-quickstart.md +191 -0
- package/docs/site/en/quick-start.md +79 -0
- package/docs/site/publish-now.md +166 -0
- package/docs/site/quick-start.md +128 -0
- package/docs/site/release-process.md +94 -0
- package/docs/site/security-faq.md +55 -0
- package/docs/site/team-rollout.md +59 -0
- package/package.json +55 -0
- package/schema/context-pack.schema.json +80 -0
- package/schema/external-evidence.schema.json +84 -0
- package/schema/manifest.schema.json +28 -0
- package/schema/memory-candidate.schema.json +76 -0
- package/schema/proposal.schema.json +122 -0
- package/schema/trigger-result.schema.json +47 -0
- package/templates/frontend-app/README.md +5 -0
- package/templates/generic-service/README.md +5 -0
- package/templates/java-backend/README.md +5 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export function parseFrontmatter(content) {
|
|
2
|
+
if (!content.startsWith("---\n")) {
|
|
3
|
+
return { metadata: null, body: content };
|
|
4
|
+
}
|
|
5
|
+
const end = content.indexOf("\n---", 4);
|
|
6
|
+
if (end < 0) {
|
|
7
|
+
return { metadata: null, body: content };
|
|
8
|
+
}
|
|
9
|
+
const raw = content.slice(4, end).split(/\r?\n/);
|
|
10
|
+
const body = content.slice(end + 4).replace(/^\r?\n/, "");
|
|
11
|
+
const metadata = { source_files: [], source_hashes: {} };
|
|
12
|
+
let section = "";
|
|
13
|
+
for (const line of raw) {
|
|
14
|
+
if (!line.trim()) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (/^[A-Za-z0-9_]+:\s*$/.test(line)) {
|
|
18
|
+
const key = line.replace(":", "").trim();
|
|
19
|
+
section = key === "source_files" || key === "source_hashes" || key === "related_docs" ? key : "";
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if ((section === "source_files" || section === "related_docs") && line.trim().startsWith("- ")) {
|
|
23
|
+
const item = line.trim().slice(2).trim();
|
|
24
|
+
if (section === "source_files")
|
|
25
|
+
metadata.source_files.push(item);
|
|
26
|
+
if (section === "related_docs")
|
|
27
|
+
metadata.related_docs = [...(metadata.related_docs ?? []), item];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (section === "source_hashes" && line.startsWith(" ")) {
|
|
31
|
+
const index = line.indexOf(":");
|
|
32
|
+
if (index > 0) {
|
|
33
|
+
metadata.source_hashes[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const index = line.indexOf(":");
|
|
38
|
+
if (index > 0) {
|
|
39
|
+
const key = line.slice(0, index).trim();
|
|
40
|
+
const value = line.slice(index + 1).trim();
|
|
41
|
+
section = "";
|
|
42
|
+
if (key === "kb_schema")
|
|
43
|
+
metadata.kb_schema = value;
|
|
44
|
+
if (key === "generated_by")
|
|
45
|
+
metadata.generated_by = value;
|
|
46
|
+
if (key === "review_status")
|
|
47
|
+
metadata.review_status = value;
|
|
48
|
+
if (key === "memory_type" && isMemoryType(value))
|
|
49
|
+
metadata.memory_type = value;
|
|
50
|
+
if (key === "topic")
|
|
51
|
+
metadata.topic = value;
|
|
52
|
+
if (key === "scope")
|
|
53
|
+
metadata.scope = value;
|
|
54
|
+
if (key === "confidence") {
|
|
55
|
+
const confidence = Number(value);
|
|
56
|
+
if (Number.isFinite(confidence))
|
|
57
|
+
metadata.confidence = confidence;
|
|
58
|
+
}
|
|
59
|
+
if (key === "owner")
|
|
60
|
+
metadata.owner = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { metadata, body };
|
|
64
|
+
}
|
|
65
|
+
export function buildFrontmatter(metadata) {
|
|
66
|
+
const lines = ["---", `kb_schema: ${metadata.kb_schema ?? "1"}`, "source_files:"];
|
|
67
|
+
for (const source of metadata.source_files) {
|
|
68
|
+
lines.push(` - ${source}`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("source_hashes:");
|
|
71
|
+
for (const [source, hash] of Object.entries(metadata.source_hashes)) {
|
|
72
|
+
lines.push(` ${source}: ${hash}`);
|
|
73
|
+
}
|
|
74
|
+
lines.push(`generated_by: ${metadata.generated_by ?? "project-atlas"}`);
|
|
75
|
+
lines.push(`review_status: ${metadata.review_status ?? "draft"}`);
|
|
76
|
+
if (metadata.memory_type)
|
|
77
|
+
lines.push(`memory_type: ${metadata.memory_type}`);
|
|
78
|
+
if (metadata.topic)
|
|
79
|
+
lines.push(`topic: ${metadata.topic}`);
|
|
80
|
+
if (metadata.scope)
|
|
81
|
+
lines.push(`scope: ${metadata.scope}`);
|
|
82
|
+
if (metadata.confidence !== undefined)
|
|
83
|
+
lines.push(`confidence: ${metadata.confidence}`);
|
|
84
|
+
if (metadata.owner)
|
|
85
|
+
lines.push(`owner: ${metadata.owner}`);
|
|
86
|
+
if (metadata.related_docs?.length) {
|
|
87
|
+
lines.push("related_docs:");
|
|
88
|
+
for (const doc of metadata.related_docs) {
|
|
89
|
+
lines.push(` - ${doc}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
lines.push("---", "");
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
export function ensureKnowledgeFrontmatter(content, metadata) {
|
|
96
|
+
if (content.startsWith("---\n")) {
|
|
97
|
+
return content;
|
|
98
|
+
}
|
|
99
|
+
return `${buildFrontmatter(metadata)}${content}`;
|
|
100
|
+
}
|
|
101
|
+
function isMemoryType(value) {
|
|
102
|
+
return value === "decision" || value === "experience" || value === "project_fact";
|
|
103
|
+
}
|
package/dist/index.js
ADDED
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
|
|
3
|
+
import * as z from "zod/v4";
|
|
4
|
+
import { runCliCapture } from "./core.js";
|
|
5
|
+
const help = [
|
|
6
|
+
"Usage: project-atlas-mcp",
|
|
7
|
+
"",
|
|
8
|
+
"Starts the local stdio MCP server for project-atlas.",
|
|
9
|
+
"",
|
|
10
|
+
"Tools:",
|
|
11
|
+
" project_atlas_scan",
|
|
12
|
+
" project_atlas_context",
|
|
13
|
+
" project_atlas_stale",
|
|
14
|
+
" project_atlas_propose",
|
|
15
|
+
" project_atlas_remember",
|
|
16
|
+
" project_atlas_check",
|
|
17
|
+
" project_atlas_review_summary",
|
|
18
|
+
"",
|
|
19
|
+
"No apply tool is exposed. A human must run project-atlas apply in a terminal.",
|
|
20
|
+
].join("\n");
|
|
21
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
22
|
+
console.log(help);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: "project-atlas",
|
|
27
|
+
version: "0.1.0",
|
|
28
|
+
});
|
|
29
|
+
server.registerTool("project_atlas_scan", {
|
|
30
|
+
title: "Project Atlas Scan",
|
|
31
|
+
description: "Scan project shape and optional external code evidence. This tool does not write knowledge files.",
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
34
|
+
mode: z.enum(["full", "changed"]).optional().describe("Scan mode. Defaults to full."),
|
|
35
|
+
external_evidence_file: z.string().optional().describe("JSON file with external repo map or code graph evidence."),
|
|
36
|
+
}),
|
|
37
|
+
}, async ({ repo, mode, external_evidence_file }) => cliTool([
|
|
38
|
+
"scan",
|
|
39
|
+
"--repo",
|
|
40
|
+
repo || process.cwd(),
|
|
41
|
+
"--mode",
|
|
42
|
+
mode || "full",
|
|
43
|
+
...flag("external-evidence-file", external_evidence_file),
|
|
44
|
+
]));
|
|
45
|
+
server.registerTool("project_atlas_context", {
|
|
46
|
+
title: "Project Atlas Context",
|
|
47
|
+
description: "Read a compact project-atlas context pack. This tool never writes files.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
50
|
+
query: z.string().optional().describe("One or more keywords. Any keyword may match."),
|
|
51
|
+
source_file: z.string().optional().describe("Return knowledge docs whose source_files include this path."),
|
|
52
|
+
memory_type: z.enum(["decision", "experience", "project_fact"]).optional().describe("Filter project memory type."),
|
|
53
|
+
topic: z.string().optional().describe("Filter project memories by topic substring."),
|
|
54
|
+
scope: z.string().optional().describe("Filter project memories by scope substring."),
|
|
55
|
+
budget: z.number().int().positive().optional().describe("Maximum context characters. Defaults to 8000."),
|
|
56
|
+
format: z.enum(["markdown", "json"]).optional().describe("Output format. Defaults to markdown."),
|
|
57
|
+
}),
|
|
58
|
+
}, async ({ repo, query, source_file, memory_type, topic, scope, budget, format }) => cliTool([
|
|
59
|
+
"context",
|
|
60
|
+
"--repo",
|
|
61
|
+
repo || process.cwd(),
|
|
62
|
+
...flag("query", query),
|
|
63
|
+
...flag("source-file", source_file),
|
|
64
|
+
...flag("memory-type", memory_type),
|
|
65
|
+
...flag("topic", topic),
|
|
66
|
+
...flag("scope", scope),
|
|
67
|
+
...flag("budget", budget),
|
|
68
|
+
...flag("format", format),
|
|
69
|
+
]));
|
|
70
|
+
server.registerTool("project_atlas_stale", {
|
|
71
|
+
title: "Project Atlas Stale",
|
|
72
|
+
description: "Check knowledge docs against source file hashes. This tool never writes files.",
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
75
|
+
format: z.enum(["markdown", "json"]).optional().describe("Output format. Defaults to markdown."),
|
|
76
|
+
}),
|
|
77
|
+
}, async ({ repo, format }) => cliTool(["stale", "--repo", repo || process.cwd(), ...flag("format", format)]));
|
|
78
|
+
server.registerTool("project_atlas_propose", {
|
|
79
|
+
title: "Project Atlas Propose",
|
|
80
|
+
description: "Create reviewable knowledge update evidence. This tool cannot apply the proposal.",
|
|
81
|
+
inputSchema: z.object({
|
|
82
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
83
|
+
updates_file: z.string().optional().describe("JSON file with source_files, external_evidence, and updates."),
|
|
84
|
+
target: z.string().optional().describe("Single target under knowledge/**."),
|
|
85
|
+
content_file: z.string().optional().describe("Markdown content for a single target."),
|
|
86
|
+
external_evidence_file: z.string().optional().describe("JSON file with external repo map or code graph evidence."),
|
|
87
|
+
reason: z.string().optional().describe("Human-readable proposal reason."),
|
|
88
|
+
inherit_source_metadata: z.boolean().optional().describe("Merge existing target source_files into the proposal."),
|
|
89
|
+
}),
|
|
90
|
+
}, async ({ repo, updates_file, target, content_file, external_evidence_file, reason, inherit_source_metadata }) => {
|
|
91
|
+
const output = await cliTool([
|
|
92
|
+
"propose",
|
|
93
|
+
"--repo",
|
|
94
|
+
repo || process.cwd(),
|
|
95
|
+
...flag("updates-file", updates_file),
|
|
96
|
+
...flag("target", target),
|
|
97
|
+
...flag("content-file", content_file),
|
|
98
|
+
...flag("external-evidence-file", external_evidence_file),
|
|
99
|
+
...flag("reason", reason),
|
|
100
|
+
...(inherit_source_metadata ? ["--inherit-source-metadata"] : []),
|
|
101
|
+
]);
|
|
102
|
+
return appendText(output, "\nNo apply tool is available. A human must run project-atlas apply in a terminal.");
|
|
103
|
+
});
|
|
104
|
+
server.registerTool("project_atlas_remember", {
|
|
105
|
+
title: "Project Atlas Remember",
|
|
106
|
+
description: "Create reviewable project memory update evidence. This tool cannot apply the proposal.",
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
109
|
+
candidate_file: z.string().describe("JSON memory candidate file."),
|
|
110
|
+
reason: z.string().describe("Human-readable proposal reason."),
|
|
111
|
+
format: z.enum(["markdown", "json"]).optional().describe("Output format. Defaults to markdown."),
|
|
112
|
+
replace_existing: z.boolean().optional().describe("Allow proposal generation for existing target files."),
|
|
113
|
+
}),
|
|
114
|
+
}, async ({ repo, candidate_file, reason, format, replace_existing }) => {
|
|
115
|
+
const output = await cliTool([
|
|
116
|
+
"remember",
|
|
117
|
+
"--repo",
|
|
118
|
+
repo || process.cwd(),
|
|
119
|
+
"--candidate-file",
|
|
120
|
+
candidate_file,
|
|
121
|
+
"--reason",
|
|
122
|
+
reason,
|
|
123
|
+
...flag("format", format),
|
|
124
|
+
...(replace_existing ? ["--replace-existing"] : []),
|
|
125
|
+
]);
|
|
126
|
+
return appendText(output, "\nNo apply tool is available. A human must run project-atlas apply in a terminal.");
|
|
127
|
+
});
|
|
128
|
+
server.registerTool("project_atlas_check", {
|
|
129
|
+
title: "Project Atlas Check",
|
|
130
|
+
description: "Check project knowledge health. This tool never writes files.",
|
|
131
|
+
inputSchema: z.object({
|
|
132
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
133
|
+
format: z.enum(["markdown", "json"]).optional().describe("Output format. Defaults to markdown."),
|
|
134
|
+
}),
|
|
135
|
+
}, async ({ repo, format }) => cliTool(["check", "--repo", repo || process.cwd(), ...flag("format", format)]));
|
|
136
|
+
server.registerTool("project_atlas_review_summary", {
|
|
137
|
+
title: "Project Atlas Review Summary",
|
|
138
|
+
description: "Print reviewer-friendly proposal evidence. This tool never applies proposals.",
|
|
139
|
+
inputSchema: z.object({
|
|
140
|
+
repo: z.string().optional().describe("Git repository path. Defaults to the MCP server working directory."),
|
|
141
|
+
proposal_id: z.string().optional().describe("Proposal id. Defaults to latest.json."),
|
|
142
|
+
}),
|
|
143
|
+
}, async ({ repo, proposal_id }) => cliTool(["review-summary", "--repo", repo || process.cwd(), ...flag("proposal-id", proposal_id)]));
|
|
144
|
+
const transport = new StdioServerTransport();
|
|
145
|
+
await server.connect(transport);
|
|
146
|
+
let cliQueue = Promise.resolve();
|
|
147
|
+
async function cliTool(args) {
|
|
148
|
+
const run = async () => {
|
|
149
|
+
try {
|
|
150
|
+
return { content: [{ type: "text", text: await runCliCapture(args) }] };
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const result = cliQueue.then(run, run);
|
|
158
|
+
cliQueue = result.then(() => undefined, () => undefined);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
function appendText(result, text) {
|
|
162
|
+
return {
|
|
163
|
+
...result,
|
|
164
|
+
content: result.content.map((item, index) => (index === 0 ? { ...item, text: `${item.text}${text}` } : item)),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function flag(name, value) {
|
|
168
|
+
if (value === undefined || value === "") {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return [`--${name}`, String(value)];
|
|
172
|
+
}
|
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { changedFiles, currentCommit, runGit, walkFiles, worktreeHash } from "./utils.js";
|
|
4
|
+
export function scanRepo(repo, mode, externalEvidence = []) {
|
|
5
|
+
const allFiles = walkFiles(repo);
|
|
6
|
+
const scopedFiles = mode === "changed" ? changedFiles(repo) : allFiles;
|
|
7
|
+
const entries = {
|
|
8
|
+
controller: [],
|
|
9
|
+
service: [],
|
|
10
|
+
feign: [],
|
|
11
|
+
tasks: [],
|
|
12
|
+
mq: [],
|
|
13
|
+
remote: [],
|
|
14
|
+
config: [],
|
|
15
|
+
};
|
|
16
|
+
for (const rel of scopedFiles) {
|
|
17
|
+
const type = entryType(rel);
|
|
18
|
+
if (type) {
|
|
19
|
+
entries[type].push({ path: rel, name: titleFromPath(rel) });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const pomFiles = allFiles.filter((rel) => rel === "pom.xml" || rel.endsWith("/pom.xml")).sort();
|
|
23
|
+
const modules = pomFiles.map((rel) => parsePom(repo, rel));
|
|
24
|
+
const knowledgeFiles = allFiles.filter((rel) => rel.startsWith("knowledge/")).sort();
|
|
25
|
+
return {
|
|
26
|
+
schema_version: "1.0",
|
|
27
|
+
mode,
|
|
28
|
+
repo,
|
|
29
|
+
base_commit: currentCommit(repo),
|
|
30
|
+
worktree_diff_hash: worktreeHash(repo),
|
|
31
|
+
changed_files: changedFiles(repo),
|
|
32
|
+
project: {
|
|
33
|
+
name: path.basename(repo),
|
|
34
|
+
maven: modules[0] ?? {},
|
|
35
|
+
modules,
|
|
36
|
+
},
|
|
37
|
+
entries,
|
|
38
|
+
knowledge: {
|
|
39
|
+
has_manifest: existsSync(path.join(repo, "knowledge/manifest.json")),
|
|
40
|
+
files: knowledgeFiles,
|
|
41
|
+
empty_sections: knowledgeFiles
|
|
42
|
+
.filter((rel) => rel.endsWith("README.md"))
|
|
43
|
+
.filter((rel) => read(repo, rel).trim().split(/\r?\n/).length <= 3),
|
|
44
|
+
},
|
|
45
|
+
candidates: detectCandidates(scopedFiles),
|
|
46
|
+
sensitive_config_findings: sensitiveFindings(repo, scopedFiles.length ? scopedFiles : allFiles),
|
|
47
|
+
external_evidence: externalEvidence,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function parsePom(repo, rel) {
|
|
51
|
+
const text = read(repo, rel);
|
|
52
|
+
const tag = (name) => text.match(new RegExp(`<${name}>\\s*([^<]+?)\\s*</${name}>`))?.[1] ?? "";
|
|
53
|
+
const modules = [...text.matchAll(/<module>\s*([^<]+?)\s*<\/module>/g)].map((match) => match[1]);
|
|
54
|
+
return { path: rel, groupId: tag("groupId"), artifactId: tag("artifactId"), version: tag("version"), modules };
|
|
55
|
+
}
|
|
56
|
+
function read(repo, rel) {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(path.join(repo, rel), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function entryType(rel) {
|
|
65
|
+
if (!rel.endsWith(".java"))
|
|
66
|
+
return "";
|
|
67
|
+
if (rel.includes("/controller/") || /Controller\.java$/.test(rel))
|
|
68
|
+
return "controller";
|
|
69
|
+
if (rel.includes("/service/") || /Service(Impl)?\.java$/.test(rel))
|
|
70
|
+
return "service";
|
|
71
|
+
if (rel.includes("/feign/") || /Feign(Service)?(Impl)?\.java$/.test(rel))
|
|
72
|
+
return "feign";
|
|
73
|
+
if (rel.includes("/tasks/") || /Task\.java$|Scheduled.*\.java$/.test(rel))
|
|
74
|
+
return "tasks";
|
|
75
|
+
if (rel.includes("/mq/") || /Consumer\.java$|Producer\.java$/.test(rel))
|
|
76
|
+
return "mq";
|
|
77
|
+
if (rel.includes("/remote/") || /Remote.*\.java$/.test(rel))
|
|
78
|
+
return "remote";
|
|
79
|
+
if (rel.includes("/config/") || /Config(uration)?\.java$/.test(rel))
|
|
80
|
+
return "config";
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
function titleFromPath(rel) {
|
|
84
|
+
return path.basename(rel).replace(/\.(java|xml|ya?ml|properties|md)$/i, "");
|
|
85
|
+
}
|
|
86
|
+
function detectCandidates(files) {
|
|
87
|
+
const text = files.join("\n").toLowerCase();
|
|
88
|
+
const has = (items) => items.some((item) => text.includes(item));
|
|
89
|
+
const domains = [];
|
|
90
|
+
const workflows = [];
|
|
91
|
+
const integrations = [];
|
|
92
|
+
const risks = [];
|
|
93
|
+
if (has(["goods", "商品"]))
|
|
94
|
+
domains.push({ target: "knowledge/domains/goods-master.md", reason: "goods domain entry detected" });
|
|
95
|
+
if (has(["precisionorder", "精准订货"]))
|
|
96
|
+
domains.push({ target: "knowledge/domains/precision-order.md", reason: "precision order entry detected" });
|
|
97
|
+
if (has(["hddatasync", "hd", "海鼎"]))
|
|
98
|
+
workflows.push({ target: "knowledge/workflows/hd-sync.md", reason: "HD sync entry detected" });
|
|
99
|
+
if (has(["precisionorder", "精准订货"]))
|
|
100
|
+
workflows.push({ target: "knowledge/workflows/precision-order-flow.md", reason: "precision order workflow detected" });
|
|
101
|
+
if (has(["mall", "feign", "mq", "remote", "thirdapi"]))
|
|
102
|
+
integrations.push({ target: "knowledge/integrations/external-systems.md", reason: "external integration entry detected" });
|
|
103
|
+
if (has(["datafix", "inner", "thirdapi", "password", "secret", "token", "accesskey"]))
|
|
104
|
+
risks.push({ target: "knowledge/quality/risk-hotspots.md", reason: "risk-sensitive entry detected" });
|
|
105
|
+
return { domains, workflows, integrations, risks };
|
|
106
|
+
}
|
|
107
|
+
function sensitiveFindings(repo, files) {
|
|
108
|
+
const findings = [];
|
|
109
|
+
const configFiles = files.filter((rel) => /(^|\/)application.*\.(ya?ml|properties)$/.test(rel) || rel.includes("/config/"));
|
|
110
|
+
const rules = [
|
|
111
|
+
["builtin.secret.password", /password\s*[:=]\s*\S{3,}/i],
|
|
112
|
+
["builtin.secret.token", /token\s*[:=]\s*\S{8,}/i],
|
|
113
|
+
["builtin.secret.access-key", /accessKey(Id|Secret)?\s*[:=]\s*\S{8,}/i],
|
|
114
|
+
["builtin.secret.generic", /secret\s*[:=]\s*\S{8,}/i],
|
|
115
|
+
];
|
|
116
|
+
for (const rel of configFiles) {
|
|
117
|
+
const text = read(repo, rel);
|
|
118
|
+
for (const [ruleId, pattern] of rules) {
|
|
119
|
+
if (pattern.test(text)) {
|
|
120
|
+
findings.push({ path: rel, rule_id: ruleId, rule_category: "secret", action: "redact_value" });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
export function gitChangedFiles(repo) {
|
|
127
|
+
return runGit(repo, ["diff", "--name-only"]).split(/\r?\n/).filter(Boolean);
|
|
128
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function runGit(repo, args) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function resolveRepo(input = ".") {
|
|
14
|
+
if (!existsSync(input)) {
|
|
15
|
+
throw new Error(`Repository path does not exist: ${input}`);
|
|
16
|
+
}
|
|
17
|
+
const root = runGit(input, ["rev-parse", "--show-toplevel"]);
|
|
18
|
+
if (!root) {
|
|
19
|
+
throw new Error("project-atlas currently supports Git repositories only.");
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
export function currentCommit(repo) {
|
|
24
|
+
return runGit(repo, ["rev-parse", "HEAD"]);
|
|
25
|
+
}
|
|
26
|
+
export function toPosix(value) {
|
|
27
|
+
return value.split(path.sep).join("/");
|
|
28
|
+
}
|
|
29
|
+
export function sha256Text(value) {
|
|
30
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
export function fileHash(filePath) {
|
|
33
|
+
if (!existsSync(filePath)) {
|
|
34
|
+
return "sha256:missing";
|
|
35
|
+
}
|
|
36
|
+
return `sha256:${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
export function repoFileHash(repo, rel) {
|
|
39
|
+
return fileHash(path.join(repo, rel));
|
|
40
|
+
}
|
|
41
|
+
export function writeJson(filePath, value) {
|
|
42
|
+
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
43
|
+
}
|
|
44
|
+
export function readJson(filePath) {
|
|
45
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
export function ensureDir(dir) {
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
export function writeIfMissing(filePath, content) {
|
|
51
|
+
if (existsSync(filePath)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
ensureDir(path.dirname(filePath));
|
|
55
|
+
writeFileSync(filePath, content, "utf8");
|
|
56
|
+
}
|
|
57
|
+
export function walkFiles(repo, dirRel = ".") {
|
|
58
|
+
const output = [];
|
|
59
|
+
const root = path.join(repo, dirRel);
|
|
60
|
+
if (!existsSync(root)) {
|
|
61
|
+
return output;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
64
|
+
const rel = dirRel === "." ? entry.name : path.posix.join(toPosix(dirRel), entry.name);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
if ([".git", ".project-atlas", ".opencode", ".code-review-graph", "node_modules", "dist", "target"].includes(entry.name)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
output.push(...walkFiles(repo, rel));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
output.push(rel);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return output.sort();
|
|
76
|
+
}
|
|
77
|
+
export function changedFiles(repo) {
|
|
78
|
+
const values = new Set();
|
|
79
|
+
for (const args of [
|
|
80
|
+
["diff", "--name-only"],
|
|
81
|
+
["diff", "--cached", "--name-only"],
|
|
82
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
83
|
+
]) {
|
|
84
|
+
for (const line of runGit(repo, args).split(/\r?\n/)) {
|
|
85
|
+
if (line && !line.startsWith(".project-atlas/")) {
|
|
86
|
+
values.add(line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...values].sort();
|
|
91
|
+
}
|
|
92
|
+
export function worktreeHash(repo) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
parts.push(runGit(repo, ["diff", "--binary"]));
|
|
95
|
+
parts.push(runGit(repo, ["diff", "--cached", "--binary"]));
|
|
96
|
+
for (const rel of runGit(repo, ["ls-files", "--others", "--exclude-standard"]).split(/\r?\n/).filter(Boolean).sort()) {
|
|
97
|
+
if (rel.startsWith(".project-atlas/")) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const abs = path.join(repo, rel);
|
|
101
|
+
if (!existsSync(abs) || statSync(abs).size > 1024 * 1024) {
|
|
102
|
+
parts.push(`${rel}\tlarge-or-missing`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
parts.push(`${rel}\t${fileHash(abs)}`);
|
|
106
|
+
}
|
|
107
|
+
return sha256Text(parts.join("\n"));
|
|
108
|
+
}
|
|
109
|
+
export function updateGitignore(repo) {
|
|
110
|
+
const block = [
|
|
111
|
+
"# >>> project-atlas >>>",
|
|
112
|
+
".project-atlas/",
|
|
113
|
+
"knowledge/**/.kbtmp.*",
|
|
114
|
+
"knowledge/**/*.kbtmp.*",
|
|
115
|
+
"# <<< project-atlas <<<",
|
|
116
|
+
"",
|
|
117
|
+
].join("\n");
|
|
118
|
+
const filePath = path.join(repo, ".gitignore");
|
|
119
|
+
const current = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
120
|
+
const start = "# >>> project-atlas >>>";
|
|
121
|
+
const end = "# <<< project-atlas <<<";
|
|
122
|
+
if (current.includes(start) && current.includes(end)) {
|
|
123
|
+
const next = current.replace(new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m"), block);
|
|
124
|
+
writeFileSync(filePath, next, "utf8");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
writeFileSync(filePath, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${current ? "\n" : ""}${block}`, "utf8");
|
|
128
|
+
}
|
|
129
|
+
export function ensureEvidenceIgnored(repo) {
|
|
130
|
+
const ignored = runGit(repo, ["check-ignore", ".project-atlas/proposals/.keep"]);
|
|
131
|
+
if (!ignored) {
|
|
132
|
+
throw new Error(".project-atlas/proposals/.keep is not ignored by Git. Run project-atlas init first.");
|
|
133
|
+
}
|
|
134
|
+
const tracked = runGit(repo, ["ls-files", ".project-atlas"]);
|
|
135
|
+
if (tracked) {
|
|
136
|
+
throw new Error(`.project-atlas is tracked by Git and must be removed from the index first:\n${tracked}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function proposalRoot(repo) {
|
|
140
|
+
return path.join(repo, ".project-atlas", "proposals");
|
|
141
|
+
}
|
|
142
|
+
export function validateKnowledgeTarget(target) {
|
|
143
|
+
if (/[\r\n\0]/.test(target)) {
|
|
144
|
+
throw new Error(`Invalid target path: ${target}`);
|
|
145
|
+
}
|
|
146
|
+
const normalized = toPosix(path.posix.normalize(target));
|
|
147
|
+
if (normalized.startsWith("../") || normalized.includes("/../") || path.isAbsolute(normalized)) {
|
|
148
|
+
throw new Error(`Invalid target path: ${target}`);
|
|
149
|
+
}
|
|
150
|
+
if (normalized === "knowledge/manifest.json") {
|
|
151
|
+
throw new Error("proposal cannot modify knowledge/manifest.json in v1.");
|
|
152
|
+
}
|
|
153
|
+
if (normalized.startsWith("knowledge/assets/")) {
|
|
154
|
+
throw new Error("proposal cannot write knowledge/assets/ in v1.");
|
|
155
|
+
}
|
|
156
|
+
if (!normalized.startsWith("knowledge/")) {
|
|
157
|
+
throw new Error(`proposal target must be under knowledge/**: ${target}`);
|
|
158
|
+
}
|
|
159
|
+
return normalized;
|
|
160
|
+
}
|
|
161
|
+
export function replaceFileAtomic(targetPath, content) {
|
|
162
|
+
ensureDir(path.dirname(targetPath));
|
|
163
|
+
const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.kbtmp.${process.pid}`);
|
|
164
|
+
writeFileSync(tempPath, content, "utf8");
|
|
165
|
+
renameSync(tempPath, targetPath);
|
|
166
|
+
}
|
|
167
|
+
export function removeFileIfExists(filePath) {
|
|
168
|
+
if (existsSync(filePath)) {
|
|
169
|
+
unlinkSync(filePath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function escapeRegExp(value) {
|
|
173
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
174
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Project Atlas 文档
|
|
2
|
+
|
|
3
|
+
这里是 `project-atlas` 的轻量文档入口。文档保持 Markdown 形态,方便直接在 GitHub、编辑器、团队知识库或 agent 上下文里阅读。
|
|
4
|
+
|
|
5
|
+
English documentation is available at [en/README.md](en/README.md).
|
|
6
|
+
|
|
7
|
+
如果是 agent 接入,不要从长文档开始。直接读 [Agent Quickstart](agent-quickstart.md),然后按里面的命令执行。
|
|
8
|
+
|
|
9
|
+
## 推荐阅读顺序
|
|
10
|
+
|
|
11
|
+
1. [Agent Quickstart](agent-quickstart.md)
|
|
12
|
+
2. [快速开始](quick-start.md)
|
|
13
|
+
3. [最佳实践](best-practices.md)
|
|
14
|
+
4. [团队落地流程](team-rollout.md)
|
|
15
|
+
5. [安全 FAQ](security-faq.md)
|
|
16
|
+
6. [发布流程](release-process.md)
|
|
17
|
+
7. [现在发布指南](publish-now.md)
|
|
18
|
+
|
|
19
|
+
## 10 分钟体验路径
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
node dist/index.js init --repo /tmp/project-atlas-demo
|
|
25
|
+
node dist/index.js context --repo /tmp/project-atlas-demo --query demo
|
|
26
|
+
node dist/index.js propose --repo /tmp/project-atlas-demo --updates-file updates.json --reason "demo update"
|
|
27
|
+
node dist/index.js review-summary --repo /tmp/project-atlas-demo
|
|
28
|
+
node dist/index.js check --repo /tmp/project-atlas-demo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
真实写入仍然要由人回到终端执行:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node dist/index.js apply --repo /tmp/project-atlas-demo --proposal-id <id> --confirm
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Project Atlas 的核心边界是清楚的。Agent 可以读取上下文和生成 proposal,不能直接 apply。
|
|
38
|
+
|
|
39
|
+
如果要沉淀项目级记忆,先准备结构化 JSON 候选文件,再生成 proposal:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
node dist/index.js remember --repo /tmp/project-atlas-demo --candidate-file memory.json --reason "capture project memory"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
项目记忆仍然写入 `knowledge/`。它是团队共享知识,不读取个人聊天记录,也不绕过 review。
|
|
46
|
+
|
|
47
|
+
准备发布 npm 包时,先看 [现在发布指南](publish-now.md)。它给出当前仓库可直接执行的发布前检查、npm 登录、首次发布、打 tag 和推送步骤。
|