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
package/dist/core.js
ADDED
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { buildFrontmatter, ensureKnowledgeFrontmatter, parseFrontmatter } from "./frontmatter.js";
|
|
6
|
+
import { scanRepo } from "./scanner.js";
|
|
7
|
+
import { currentCommit, ensureDir, ensureEvidenceIgnored, fileHash, proposalRoot, readJson, removeFileIfExists, replaceFileAtomic, repoFileHash, resolveRepo, sha256Text, updateGitignore, validateKnowledgeTarget, walkFiles, worktreeHash, writeIfMissing, writeJson, } from "./utils.js";
|
|
8
|
+
const commandOptions = {
|
|
9
|
+
init: ["repo", "template"],
|
|
10
|
+
scan: ["repo", "mode", "external-evidence-file"],
|
|
11
|
+
context: ["repo", "query", "source-file", "budget", "max-context-chars", "format", "memory-type", "topic", "scope"],
|
|
12
|
+
stale: ["repo", "format"],
|
|
13
|
+
propose: ["repo", "target", "content-file", "updates-file", "reason", "inherit-source-metadata", "external-evidence-file"],
|
|
14
|
+
remember: ["repo", "candidate-file", "reason", "format", "replace-existing"],
|
|
15
|
+
check: ["repo", "format"],
|
|
16
|
+
apply: ["repo", "proposal-id", "confirm"],
|
|
17
|
+
"review-summary": ["repo", "proposal-id"],
|
|
18
|
+
cleanup: ["repo", "force"],
|
|
19
|
+
hash: ["repo", "path"],
|
|
20
|
+
};
|
|
21
|
+
const booleanOptions = {
|
|
22
|
+
propose: ["inherit-source-metadata"],
|
|
23
|
+
remember: ["replace-existing"],
|
|
24
|
+
apply: ["confirm"],
|
|
25
|
+
cleanup: ["force"],
|
|
26
|
+
};
|
|
27
|
+
const initTemplates = {
|
|
28
|
+
"generic-service": {
|
|
29
|
+
displayName: "Generic Service",
|
|
30
|
+
overview: "Summarize this generic service, who uses it, and which runtime boundaries matter most.",
|
|
31
|
+
sections: {
|
|
32
|
+
domains: "Record stable business domains, ownership terms, and key user-facing concepts for this generic service.",
|
|
33
|
+
workflows: "Record important service workflows, trigger points, and handoff rules.",
|
|
34
|
+
contracts: "Record public APIs, event contracts, file contracts, and compatibility notes.",
|
|
35
|
+
integrations: "Record upstream and downstream systems, owners, and failure handling expectations.",
|
|
36
|
+
quality: "Record test strategy, release checks, risk hotspots, and operational guardrails.",
|
|
37
|
+
decisions: "Record durable technical and product decisions with source evidence.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"java-backend": {
|
|
41
|
+
displayName: "Java Backend",
|
|
42
|
+
overview: "Summarize this Java backend service, its modules, runtime boundaries, and main controller to service responsibilities.",
|
|
43
|
+
sections: {
|
|
44
|
+
domains: "Record Java backend domain concepts, controller entry points, service ownership, and aggregate boundaries.",
|
|
45
|
+
workflows: "Record request flows, scheduled tasks, MQ consumers, transaction rules, and retry behavior.",
|
|
46
|
+
contracts: "Record REST APIs, Feign clients, DTO compatibility rules, and database contract notes.",
|
|
47
|
+
integrations: "Record middleware, downstream services, remote clients, and failure fallback rules.",
|
|
48
|
+
quality: "Record unit, integration, regression, and release checks for Java backend changes.",
|
|
49
|
+
decisions: "Record durable architecture decisions, dependency choices, and migration notes.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"frontend-app": {
|
|
53
|
+
displayName: "Frontend App",
|
|
54
|
+
overview: "Summarize this frontend app, user groups, key pages, routing boundaries, and data ownership.",
|
|
55
|
+
sections: {
|
|
56
|
+
domains: "Record user-facing concepts, page ownership, state naming, and product language.",
|
|
57
|
+
workflows: "Record routing flows, form flows, async loading rules, and empty or error states.",
|
|
58
|
+
contracts: "Record API contracts, component props, event payloads, and compatibility notes.",
|
|
59
|
+
integrations: "Record backend APIs, auth dependencies, analytics, uploads, and third-party SDKs.",
|
|
60
|
+
quality: "Record visual checks, browser coverage, build checks, and accessibility expectations.",
|
|
61
|
+
decisions: "Record durable UI, state management, routing, and dependency decisions.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const globalHelp = [
|
|
66
|
+
"Usage: project-atlas <command> [options]",
|
|
67
|
+
"",
|
|
68
|
+
"Git-first project knowledge base governance CLI.",
|
|
69
|
+
"",
|
|
70
|
+
"Commands:",
|
|
71
|
+
" init Create knowledge skeleton and local evidence directory",
|
|
72
|
+
" scan Scan project shape, candidates, and sensitive config findings",
|
|
73
|
+
" context Print a compact context pack",
|
|
74
|
+
" stale Check knowledge docs against source file hashes",
|
|
75
|
+
" propose Create reviewable knowledge update evidence",
|
|
76
|
+
" remember Create reviewable project memory proposals",
|
|
77
|
+
" check Check project knowledge health",
|
|
78
|
+
" apply Apply a proposal with TTY confirmation",
|
|
79
|
+
" review-summary Print reviewer-friendly proposal evidence",
|
|
80
|
+
" cleanup Remove stale temporary knowledge files",
|
|
81
|
+
" hash Print a repository file hash",
|
|
82
|
+
"",
|
|
83
|
+
"Examples:",
|
|
84
|
+
" project-atlas init --repo /path/to/repo",
|
|
85
|
+
" project-atlas context --repo /path/to/repo --query order --budget 8000",
|
|
86
|
+
" project-atlas remember --repo /path/to/repo --candidate-file memory.json --reason \"capture project memory\"",
|
|
87
|
+
"",
|
|
88
|
+
"Run `project-atlas <command> --help` for command details.",
|
|
89
|
+
].join("\n");
|
|
90
|
+
const commandHelp = {
|
|
91
|
+
init: [
|
|
92
|
+
"Usage: project-atlas init --repo <repo> [--template <generic-service|java-backend|frontend-app>]",
|
|
93
|
+
"",
|
|
94
|
+
"Options:",
|
|
95
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
96
|
+
" --template <name> Initial knowledge wording template. Defaults to generic-service.",
|
|
97
|
+
"",
|
|
98
|
+
"Example:",
|
|
99
|
+
" project-atlas init --repo /path/to/repo --template java-backend",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
scan: [
|
|
102
|
+
"Usage: project-atlas scan --repo <repo> --mode <full|changed> [--external-evidence-file <file>]",
|
|
103
|
+
"",
|
|
104
|
+
"Options:",
|
|
105
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
106
|
+
" --mode <value> Scan mode. Use full or changed. Defaults to full.",
|
|
107
|
+
" --external-evidence-file <file> JSON file with external repo map or code graph evidence.",
|
|
108
|
+
"",
|
|
109
|
+
"Example:",
|
|
110
|
+
" project-atlas scan --repo /path/to/repo --mode changed --external-evidence-file evidence.json",
|
|
111
|
+
].join("\n"),
|
|
112
|
+
context: [
|
|
113
|
+
"Usage: project-atlas context --repo <repo> [--query <text>] [--source-file <path>] [--memory-type <decision|experience|project_fact>] [--topic <text>] [--scope <text>] [--budget <chars>] [--format <markdown|json>]",
|
|
114
|
+
"",
|
|
115
|
+
"Options:",
|
|
116
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
117
|
+
" --query <text> One or more keywords. Any keyword may match.",
|
|
118
|
+
" --source-file <path> Return knowledge docs whose source_files include this path.",
|
|
119
|
+
" --memory-type <value> Filter project memory type.",
|
|
120
|
+
" --topic <text> Filter project memories by topic substring.",
|
|
121
|
+
" --scope <text> Filter project memories by scope substring.",
|
|
122
|
+
" --budget <chars> Positive character budget. Defaults to 8000.",
|
|
123
|
+
" --format <value> Output format. Use markdown or json. Defaults to markdown.",
|
|
124
|
+
"",
|
|
125
|
+
"Example:",
|
|
126
|
+
" project-atlas context --repo /path/to/repo --query order --budget 8000 --format json",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
stale: [
|
|
129
|
+
"Usage: project-atlas stale --repo <repo> [--format <markdown|json>]",
|
|
130
|
+
"",
|
|
131
|
+
"Options:",
|
|
132
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
133
|
+
" --format <value> Output format. Use markdown or json. Defaults to markdown.",
|
|
134
|
+
"",
|
|
135
|
+
"Example:",
|
|
136
|
+
" project-atlas stale --repo /path/to/repo --format json",
|
|
137
|
+
].join("\n"),
|
|
138
|
+
propose: [
|
|
139
|
+
"Usage: project-atlas propose --repo <repo> --updates-file <file> --reason <text>",
|
|
140
|
+
"Usage: project-atlas propose --repo <repo> --target <knowledge/file.md> --content-file <file> --reason <text>",
|
|
141
|
+
"",
|
|
142
|
+
"Options:",
|
|
143
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
144
|
+
" --updates-file <file> JSON file with source_files and updates.",
|
|
145
|
+
" --target <path> Single target under knowledge/**.",
|
|
146
|
+
" --content-file <file> Markdown content for a single target.",
|
|
147
|
+
" --external-evidence-file <file> JSON file with external repo map or code graph evidence.",
|
|
148
|
+
" --reason <text> Human-readable proposal reason.",
|
|
149
|
+
" --inherit-source-metadata Merge existing target source_files into the proposal.",
|
|
150
|
+
"",
|
|
151
|
+
"Example:",
|
|
152
|
+
" project-atlas propose --repo /path/to/repo --updates-file updates.json --external-evidence-file evidence.json --reason \"update project knowledge\"",
|
|
153
|
+
].join("\n"),
|
|
154
|
+
remember: [
|
|
155
|
+
"Usage: project-atlas remember --repo <repo> --candidate-file <file> --reason <text> [--format <markdown|json>] [--replace-existing]",
|
|
156
|
+
"",
|
|
157
|
+
"Options:",
|
|
158
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
159
|
+
" --candidate-file <file> JSON memory candidate file.",
|
|
160
|
+
" --reason <text> Human-readable proposal reason.",
|
|
161
|
+
" --format <value> Output format. Use markdown or json. Defaults to markdown.",
|
|
162
|
+
" --replace-existing Allow proposal generation for existing target files.",
|
|
163
|
+
"",
|
|
164
|
+
"Example:",
|
|
165
|
+
" project-atlas remember --repo /path/to/repo --candidate-file memory.json --reason \"capture review memory\"",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
check: [
|
|
168
|
+
"Usage: project-atlas check --repo <repo> [--format <markdown|json>]",
|
|
169
|
+
"",
|
|
170
|
+
"Options:",
|
|
171
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
172
|
+
" --format <value> Output format. Use markdown or json. Defaults to markdown.",
|
|
173
|
+
"",
|
|
174
|
+
"Example:",
|
|
175
|
+
" project-atlas check --repo /path/to/repo --format json",
|
|
176
|
+
].join("\n"),
|
|
177
|
+
apply: [
|
|
178
|
+
"Usage: project-atlas apply --repo <repo> --proposal-id <id> --confirm",
|
|
179
|
+
"",
|
|
180
|
+
"Options:",
|
|
181
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
182
|
+
" --proposal-id <id> Proposal id under .project-atlas/proposals/.",
|
|
183
|
+
" --confirm Required. Still asks for interactive TTY confirmation.",
|
|
184
|
+
"",
|
|
185
|
+
"Example:",
|
|
186
|
+
" project-atlas apply --repo /path/to/repo --proposal-id kb-20260521-120000-1 --confirm",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
"review-summary": [
|
|
189
|
+
"Usage: project-atlas review-summary --repo <repo> [--proposal-id <id>]",
|
|
190
|
+
"",
|
|
191
|
+
"Options:",
|
|
192
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
193
|
+
" --proposal-id <id> Proposal id. Defaults to latest.json.",
|
|
194
|
+
"",
|
|
195
|
+
"Example:",
|
|
196
|
+
" project-atlas review-summary --repo /path/to/repo --proposal-id kb-20260521-120000-1",
|
|
197
|
+
].join("\n"),
|
|
198
|
+
cleanup: [
|
|
199
|
+
"Usage: project-atlas cleanup --repo <repo> [--force]",
|
|
200
|
+
"",
|
|
201
|
+
"Options:",
|
|
202
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
203
|
+
" --force Remove all .kbtmp files instead of only old files.",
|
|
204
|
+
"",
|
|
205
|
+
"Example:",
|
|
206
|
+
" project-atlas cleanup --repo /path/to/repo --force",
|
|
207
|
+
].join("\n"),
|
|
208
|
+
hash: [
|
|
209
|
+
"Usage: project-atlas hash --repo <repo> --path <file>",
|
|
210
|
+
"",
|
|
211
|
+
"Options:",
|
|
212
|
+
" --repo <path> Git repository path. Defaults to current directory.",
|
|
213
|
+
" --path <file> Repository-relative file path to hash.",
|
|
214
|
+
"",
|
|
215
|
+
"Example:",
|
|
216
|
+
" project-atlas hash --repo /path/to/repo --path README.md",
|
|
217
|
+
].join("\n"),
|
|
218
|
+
};
|
|
219
|
+
export async function runCli(argv) {
|
|
220
|
+
const parsed = parseArgs(argv);
|
|
221
|
+
if (!parsed.command || parsed.command === "--help" || parsed.command === "-h") {
|
|
222
|
+
console.log(globalHelp);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (parsed.command === "help") {
|
|
226
|
+
const topic = optionalStringFlag(parsed.flags, "command");
|
|
227
|
+
console.log(topic && commandHelp[topic] ? commandHelp[topic] : globalHelp);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (!commandOptions[parsed.command]) {
|
|
231
|
+
throw new Error(`Unknown command: ${parsed.command}\n\nRun \`project-atlas --help\` to see available commands.`);
|
|
232
|
+
}
|
|
233
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
234
|
+
console.log(commandHelp[parsed.command]);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
validateFlags(parsed.command, parsed.flags);
|
|
238
|
+
switch (parsed.command) {
|
|
239
|
+
case "init":
|
|
240
|
+
cmdInit(parsed.flags);
|
|
241
|
+
return;
|
|
242
|
+
case "scan":
|
|
243
|
+
cmdScan(parsed.flags);
|
|
244
|
+
return;
|
|
245
|
+
case "context":
|
|
246
|
+
cmdContext(parsed.flags);
|
|
247
|
+
return;
|
|
248
|
+
case "stale":
|
|
249
|
+
cmdStale(parsed.flags);
|
|
250
|
+
return;
|
|
251
|
+
case "propose":
|
|
252
|
+
cmdPropose(parsed.flags);
|
|
253
|
+
return;
|
|
254
|
+
case "remember":
|
|
255
|
+
cmdRemember(parsed.flags);
|
|
256
|
+
return;
|
|
257
|
+
case "check":
|
|
258
|
+
cmdCheck(parsed.flags);
|
|
259
|
+
return;
|
|
260
|
+
case "apply":
|
|
261
|
+
await cmdApply(parsed.flags);
|
|
262
|
+
return;
|
|
263
|
+
case "review-summary":
|
|
264
|
+
cmdReviewSummary(parsed.flags);
|
|
265
|
+
return;
|
|
266
|
+
case "cleanup":
|
|
267
|
+
cmdCleanup(parsed.flags);
|
|
268
|
+
return;
|
|
269
|
+
case "hash":
|
|
270
|
+
cmdHash(parsed.flags);
|
|
271
|
+
return;
|
|
272
|
+
default:
|
|
273
|
+
throw new Error(`Unknown command: ${parsed.command || "(empty)"}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function runCliCapture(argv) {
|
|
277
|
+
const originalLog = console.log;
|
|
278
|
+
const lines = [];
|
|
279
|
+
console.log = (...args) => {
|
|
280
|
+
lines.push(args.map((arg) => (typeof arg === "string" ? arg : String(arg))).join(" "));
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
await runCli(argv);
|
|
284
|
+
return lines.length ? `${lines.join("\n")}\n` : "";
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
console.log = originalLog;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function cmdInit(flags) {
|
|
291
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
292
|
+
const templateName = templateFlag(flags);
|
|
293
|
+
const template = initTemplates[templateName];
|
|
294
|
+
updateGitignore(repo);
|
|
295
|
+
for (const dir of [
|
|
296
|
+
"knowledge/project",
|
|
297
|
+
"knowledge/domains",
|
|
298
|
+
"knowledge/workflows",
|
|
299
|
+
"knowledge/contracts",
|
|
300
|
+
"knowledge/integrations",
|
|
301
|
+
"knowledge/quality",
|
|
302
|
+
"knowledge/decisions",
|
|
303
|
+
".project-atlas/proposals",
|
|
304
|
+
]) {
|
|
305
|
+
ensureDir(path.join(repo, dir));
|
|
306
|
+
}
|
|
307
|
+
writeIfMissing(path.join(repo, ".project-atlas/proposals/.keep"), "");
|
|
308
|
+
writeIfMissing(path.join(repo, "knowledge/README.md"), `# Project Knowledge Base\n\nGit-first knowledge assets for humans and AI coding agents.\n\nTemplate: ${template.displayName}\n`);
|
|
309
|
+
writeIfMissing(path.join(repo, "knowledge/index.md"), [
|
|
310
|
+
"# Knowledge Index",
|
|
311
|
+
"",
|
|
312
|
+
"- [Project Overview](project/overview.md)",
|
|
313
|
+
"- [Domains](domains/README.md)",
|
|
314
|
+
"- [Workflows](workflows/README.md)",
|
|
315
|
+
"- [Contracts](contracts/README.md)",
|
|
316
|
+
"- [Integrations](integrations/README.md)",
|
|
317
|
+
"- [Quality](quality/README.md)",
|
|
318
|
+
"- [Decisions](decisions/README.md)",
|
|
319
|
+
"",
|
|
320
|
+
].join("\n"));
|
|
321
|
+
writeIfMissing(path.join(repo, "knowledge/glossary.md"), "# Glossary\n\nRecord stable domain terms here.\n");
|
|
322
|
+
for (const dir of ["domains", "workflows", "contracts", "integrations", "quality", "decisions"]) {
|
|
323
|
+
writeIfMissing(path.join(repo, "knowledge", dir, "README.md"), `# ${dir}\n\n${template.sections[dir]}\n`);
|
|
324
|
+
}
|
|
325
|
+
writeIfMissing(path.join(repo, "knowledge/project/overview.md"), `${buildFrontmatter({ source_files: ["README.md"], source_hashes: { "README.md": repoFileHash(repo, "README.md") } })}# Project Overview\n\n${template.overview}\n`);
|
|
326
|
+
writeIfMissing(path.join(repo, "knowledge/manifest.json"), `${JSON.stringify({
|
|
327
|
+
schema_version: "1.0",
|
|
328
|
+
max_context_chars: 8000,
|
|
329
|
+
required_files: ["knowledge/README.md", "knowledge/index.md", "knowledge/manifest.json", "knowledge/glossary.md", "knowledge/project/overview.md"],
|
|
330
|
+
evidence_dir: ".project-atlas/proposals",
|
|
331
|
+
}, null, 2)}\n`);
|
|
332
|
+
console.log(`Initialized project-atlas knowledge base at ${repo}`);
|
|
333
|
+
}
|
|
334
|
+
function cmdScan(flags) {
|
|
335
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
336
|
+
const mode = stringFlag(flags, "mode", "full");
|
|
337
|
+
if (mode !== "full" && mode !== "changed") {
|
|
338
|
+
throw usageError("scan", "--mode must be full or changed");
|
|
339
|
+
}
|
|
340
|
+
const externalEvidence = loadExternalEvidence(repo, optionalStringFlag(flags, "external-evidence-file"));
|
|
341
|
+
console.log(JSON.stringify(scanRepo(repo, mode, externalEvidence), null, 2));
|
|
342
|
+
}
|
|
343
|
+
function cmdContext(flags) {
|
|
344
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
345
|
+
const budget = typeof flags.budget === "string" ? numberFlag(flags, "budget", 8000, "context") : numberFlag(flags, "max-context-chars", 8000, "context");
|
|
346
|
+
const format = formatFlag(flags, "context");
|
|
347
|
+
const query = stringFlag(flags, "query", "");
|
|
348
|
+
const sourceFile = optionalStringFlag(flags, "source-file");
|
|
349
|
+
const memoryType = optionalMemoryTypeFlag(flags, "context");
|
|
350
|
+
const topic = optionalStringFlag(flags, "topic");
|
|
351
|
+
const scope = optionalStringFlag(flags, "scope");
|
|
352
|
+
const items = collectContextItems(repo, { query, sourceFile, memoryType, topic, scope });
|
|
353
|
+
const markdown = renderContextMarkdown(items);
|
|
354
|
+
const truncated = truncate(markdown, budget);
|
|
355
|
+
if (format === "json") {
|
|
356
|
+
console.log(JSON.stringify({
|
|
357
|
+
schema_version: "1.0",
|
|
358
|
+
budget,
|
|
359
|
+
budget_used: truncated.budget_used,
|
|
360
|
+
truncated: truncated.truncated,
|
|
361
|
+
text: truncated.text,
|
|
362
|
+
items: items.map((item) => ({ ...item, content: truncate(item.content, budget).text })),
|
|
363
|
+
}, null, 2));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
console.log(truncated.text);
|
|
367
|
+
}
|
|
368
|
+
function cmdStale(flags) {
|
|
369
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
370
|
+
const format = formatFlag(flags, "stale");
|
|
371
|
+
const items = staleItems(repo);
|
|
372
|
+
if (format === "json") {
|
|
373
|
+
console.log(JSON.stringify({ schema_version: "1.0", items }, null, 2));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
console.log(renderStaleMarkdown(items));
|
|
377
|
+
}
|
|
378
|
+
function cmdPropose(flags) {
|
|
379
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
380
|
+
ensureEvidenceIgnored(repo);
|
|
381
|
+
const target = optionalStringFlag(flags, "target");
|
|
382
|
+
const contentFile = optionalStringFlag(flags, "content-file");
|
|
383
|
+
const updatesFile = optionalStringFlag(flags, "updates-file");
|
|
384
|
+
const reason = stringFlag(flags, "reason", "Knowledge update proposal");
|
|
385
|
+
const inheritSourceMetadata = Boolean(flags["inherit-source-metadata"]);
|
|
386
|
+
const externalEvidence = loadExternalEvidence(repo, optionalStringFlag(flags, "external-evidence-file"));
|
|
387
|
+
if (target && updatesFile) {
|
|
388
|
+
throw new Error("--target and --updates-file cannot be used together");
|
|
389
|
+
}
|
|
390
|
+
const inputData = loadUpdateInput(repo, target, contentFile, updatesFile);
|
|
391
|
+
const proposal = createProposal(repo, { ...inputData, external_evidence: [...(inputData.external_evidence ?? []), ...externalEvidence] }, reason, inheritSourceMetadata);
|
|
392
|
+
console.log(`proposal_id: ${proposal.proposal_id}`);
|
|
393
|
+
console.log(`proposal_status: ${proposal.proposal_status}`);
|
|
394
|
+
console.log(`proposal_hash: ${proposal.proposal_hash}`);
|
|
395
|
+
console.log(`apply: project-atlas apply --repo ${repo} --proposal-id ${proposal.proposal_id} --confirm`);
|
|
396
|
+
}
|
|
397
|
+
function cmdRemember(flags) {
|
|
398
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
399
|
+
ensureEvidenceIgnored(repo);
|
|
400
|
+
const candidateFile = optionalStringFlag(flags, "candidate-file");
|
|
401
|
+
if (!candidateFile) {
|
|
402
|
+
throw usageError("remember", "--candidate-file is required");
|
|
403
|
+
}
|
|
404
|
+
const reason = stringFlag(flags, "reason", "");
|
|
405
|
+
if (!reason) {
|
|
406
|
+
throw usageError("remember", "--reason is required");
|
|
407
|
+
}
|
|
408
|
+
const format = formatFlag(flags, "remember");
|
|
409
|
+
const replaceExisting = Boolean(flags["replace-existing"]);
|
|
410
|
+
const inputData = memoryCandidateToUpdateInput(repo, loadMemoryCandidate(repo, candidateFile), replaceExisting);
|
|
411
|
+
const proposal = createProposal(repo, inputData, reason, false);
|
|
412
|
+
const applyCommand = `project-atlas apply --repo ${repo} --proposal-id ${proposal.proposal_id} --confirm`;
|
|
413
|
+
if (format === "json") {
|
|
414
|
+
console.log(JSON.stringify({
|
|
415
|
+
schema_version: "1.0",
|
|
416
|
+
proposal_id: proposal.proposal_id,
|
|
417
|
+
proposal_status: proposal.proposal_status,
|
|
418
|
+
proposal_hash: proposal.proposal_hash,
|
|
419
|
+
target_files: proposal.target_files,
|
|
420
|
+
apply_command: applyCommand,
|
|
421
|
+
}, null, 2));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
console.log(`proposal_id: ${proposal.proposal_id}`);
|
|
425
|
+
console.log(`proposal_status: ${proposal.proposal_status}`);
|
|
426
|
+
console.log(`proposal_hash: ${proposal.proposal_hash}`);
|
|
427
|
+
console.log(`apply: ${applyCommand}`);
|
|
428
|
+
}
|
|
429
|
+
function cmdCheck(flags) {
|
|
430
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
431
|
+
const format = formatFlag(flags, "check");
|
|
432
|
+
const result = checkKnowledge(repo);
|
|
433
|
+
if (format === "json") {
|
|
434
|
+
console.log(JSON.stringify(result, null, 2));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
console.log(renderCheckMarkdown(result));
|
|
438
|
+
}
|
|
439
|
+
async function cmdApply(flags) {
|
|
440
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
441
|
+
const proposalId = stringFlag(flags, "proposal-id", "");
|
|
442
|
+
if (!proposalId) {
|
|
443
|
+
throw new Error("--proposal-id is required");
|
|
444
|
+
}
|
|
445
|
+
if (!flags.confirm) {
|
|
446
|
+
throw new Error("--confirm is required for apply");
|
|
447
|
+
}
|
|
448
|
+
if (!process.stdin.isTTY) {
|
|
449
|
+
throw new Error("apply requires an interactive TTY confirmation.");
|
|
450
|
+
}
|
|
451
|
+
const proposalPath = path.join(proposalRoot(repo), proposalId, "proposal.json");
|
|
452
|
+
const proposal = readJson(proposalPath);
|
|
453
|
+
if (proposal.proposal_status === "blocked_sensitive") {
|
|
454
|
+
throw new Error("blocked_sensitive proposals cannot be applied.");
|
|
455
|
+
}
|
|
456
|
+
assertProposalStillFresh(repo, proposal);
|
|
457
|
+
const rl = readline.createInterface({ input, output });
|
|
458
|
+
const answer = await rl.question(`Apply proposal ${proposalId}? Type yes to continue: `);
|
|
459
|
+
rl.close();
|
|
460
|
+
if (answer.trim() !== "yes") {
|
|
461
|
+
throw new Error("apply cancelled by user.");
|
|
462
|
+
}
|
|
463
|
+
if (worktreeHash(repo) !== proposal.worktree_diff_hash) {
|
|
464
|
+
throw new Error("worktree changed during confirmation; aborting apply.");
|
|
465
|
+
}
|
|
466
|
+
assertProposalStillFresh(repo, proposal);
|
|
467
|
+
const appliedParts = [];
|
|
468
|
+
for (const operation of proposal.operations) {
|
|
469
|
+
const targetAbs = path.join(repo, operation.path);
|
|
470
|
+
replaceFileAtomic(targetAbs, operation.content);
|
|
471
|
+
appliedParts.push(`${operation.path}\t${fileHash(targetAbs)}`);
|
|
472
|
+
}
|
|
473
|
+
const appliedHash = sha256Text(appliedParts.join("\n"));
|
|
474
|
+
proposal.proposal_status = "applied";
|
|
475
|
+
proposal.applied_hash = appliedHash;
|
|
476
|
+
proposal.applied_at = new Date().toISOString();
|
|
477
|
+
writeJson(proposalPath, proposal);
|
|
478
|
+
writeEvidence(repo, proposal.proposal_id, "applied", proposal.worktree_diff_hash, proposal.proposal_hash, appliedHash);
|
|
479
|
+
console.log(`applied: ${proposal.proposal_id}`);
|
|
480
|
+
console.log(`applied_hash: ${appliedHash}`);
|
|
481
|
+
}
|
|
482
|
+
function cmdReviewSummary(flags) {
|
|
483
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
484
|
+
const proposalId = optionalStringFlag(flags, "proposal-id") ?? latestProposalId(repo);
|
|
485
|
+
if (!proposalId) {
|
|
486
|
+
throw new Error("No proposal found. Run project-atlas propose first.");
|
|
487
|
+
}
|
|
488
|
+
const proposalDir = path.join(proposalRoot(repo), proposalId);
|
|
489
|
+
const proposal = readJson(path.join(proposalDir, "proposal.json"));
|
|
490
|
+
const triggerPath = path.join(proposalDir, "trigger-result.json");
|
|
491
|
+
const trigger = existsSync(triggerPath) ? readJson(triggerPath) : null;
|
|
492
|
+
const stale = staleItems(repo);
|
|
493
|
+
const safetyStale = stale.filter((item) => !isScaffoldKnowledgeFile(item.path));
|
|
494
|
+
const dryRunSummary = dryRunSummaryLines(path.join(proposalDir, "dry-run.diff"), proposal.target_files);
|
|
495
|
+
const blocked = proposal.proposal_status === "blocked_sensitive" || proposal.sensitive_scan_result === "blocked";
|
|
496
|
+
const hasStale = safetyStale.some((item) => item.status === "stale");
|
|
497
|
+
const hasMissingSource = safetyStale.some((item) => item.status === "missing_source");
|
|
498
|
+
const hasMissingMetadata = safetyStale.some((item) => item.status === "missing_metadata");
|
|
499
|
+
const hasKnowledgeRisk = hasStale || hasMissingSource || hasMissingMetadata;
|
|
500
|
+
const canApply = proposal.proposal_status === "proposed" && !blocked && !hasKnowledgeRisk;
|
|
501
|
+
const lines = [
|
|
502
|
+
"# Project Atlas Review Summary",
|
|
503
|
+
"",
|
|
504
|
+
`- proposal_id: ${proposal.proposal_id}`,
|
|
505
|
+
`- proposal_status: ${proposal.proposal_status}`,
|
|
506
|
+
`- reason: ${proposal.reason}`,
|
|
507
|
+
`- needs_knowledge_update: ${String(trigger?.needs_knowledge_update ?? true)}`,
|
|
508
|
+
`- proposal_hash: ${proposal.proposal_hash}`,
|
|
509
|
+
`- worktree_diff_hash: ${proposal.worktree_diff_hash}`,
|
|
510
|
+
"",
|
|
511
|
+
"## Source Files",
|
|
512
|
+
...listOrNone(proposal.source_files),
|
|
513
|
+
"",
|
|
514
|
+
"## Target Files",
|
|
515
|
+
...listOrNone(proposal.target_files),
|
|
516
|
+
"",
|
|
517
|
+
"## External Evidence",
|
|
518
|
+
...externalEvidenceLines(proposal.external_evidence ?? []),
|
|
519
|
+
"",
|
|
520
|
+
"## Sensitive Scan",
|
|
521
|
+
`- result: ${proposal.sensitive_scan_result}`,
|
|
522
|
+
"",
|
|
523
|
+
"## Dry Run Summary",
|
|
524
|
+
...dryRunSummary,
|
|
525
|
+
"",
|
|
526
|
+
"## Stale Status",
|
|
527
|
+
...stale.map((item) => `- ${item.path}: ${item.status}${item.status !== "fresh" ? `; ${item.suggestion}` : ""}`),
|
|
528
|
+
"",
|
|
529
|
+
"## Review Decision",
|
|
530
|
+
`- recommendation: ${reviewDecision(proposal, { hasStale, hasMissingSource, hasMissingMetadata })}`,
|
|
531
|
+
"",
|
|
532
|
+
"## Apply Safety",
|
|
533
|
+
`- can_apply: ${canApply ? "yes" : "no"}`,
|
|
534
|
+
`- blocked_sensitive: ${blocked ? "yes" : "no"}`,
|
|
535
|
+
`- stale_documents: ${hasStale ? "yes" : "no"}`,
|
|
536
|
+
`- missing_source_documents: ${hasMissingSource ? "yes" : "no"}`,
|
|
537
|
+
`- missing_metadata_documents: ${hasMissingMetadata ? "yes" : "no"}`,
|
|
538
|
+
"",
|
|
539
|
+
"## Next Step",
|
|
540
|
+
canApply
|
|
541
|
+
? `- Run: project-atlas apply --repo ${repo} --proposal-id ${proposal.proposal_id} --confirm`
|
|
542
|
+
: nextReviewStep(proposal, hasKnowledgeRisk),
|
|
543
|
+
"",
|
|
544
|
+
];
|
|
545
|
+
console.log(lines.join("\n"));
|
|
546
|
+
}
|
|
547
|
+
function cmdCleanup(flags) {
|
|
548
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
549
|
+
const force = Boolean(flags.force);
|
|
550
|
+
let removed = 0;
|
|
551
|
+
for (const rel of walkFiles(repo).filter((item) => item.includes("knowledge/") && item.includes(".kbtmp."))) {
|
|
552
|
+
const abs = path.join(repo, rel);
|
|
553
|
+
const ageMs = Date.now() - statSync(abs).mtimeMs;
|
|
554
|
+
if (force || ageMs > 60 * 60 * 1000) {
|
|
555
|
+
removeFileIfExists(abs);
|
|
556
|
+
removed++;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
console.log(`removed_kbtmp: ${removed}`);
|
|
560
|
+
}
|
|
561
|
+
function cmdHash(flags) {
|
|
562
|
+
const repo = resolveRepo(stringFlag(flags, "repo", "."));
|
|
563
|
+
const rel = stringFlag(flags, "path", "");
|
|
564
|
+
if (!rel) {
|
|
565
|
+
throw usageError("hash", "--path is required");
|
|
566
|
+
}
|
|
567
|
+
console.log(repoFileHash(repo, rel));
|
|
568
|
+
}
|
|
569
|
+
function collectContextItems(repo, filters) {
|
|
570
|
+
const keywords = filters.query
|
|
571
|
+
.toLowerCase()
|
|
572
|
+
.split(/\s+/)
|
|
573
|
+
.map((item) => item.trim())
|
|
574
|
+
.filter(Boolean);
|
|
575
|
+
const hasMemoryFilters = Boolean(filters.memoryType || filters.topic || filters.scope);
|
|
576
|
+
const groups = filters.sourceFile || hasMemoryFilters
|
|
577
|
+
? [{ priority: 3, source_type: "knowledge", files: knowledgeMarkdownFiles(repo) }]
|
|
578
|
+
: [
|
|
579
|
+
{ priority: 1, source_type: "openspec_change", files: globFiles(repo, "openspec/changes", [".md"]) },
|
|
580
|
+
{ priority: 2, source_type: "openspec_spec", files: globFiles(repo, "openspec/specs", [".md"]) },
|
|
581
|
+
{ priority: 3, source_type: "knowledge", files: knowledgeMarkdownFiles(repo) },
|
|
582
|
+
];
|
|
583
|
+
const normalizedSourceFile = filters.sourceFile ? normalizeRepoPath(filters.sourceFile) : "";
|
|
584
|
+
const sourceMatches = (metadata) => {
|
|
585
|
+
if (!normalizedSourceFile) {
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
return Boolean(metadata?.source_files.map(normalizeRepoPath).includes(normalizedSourceFile));
|
|
589
|
+
};
|
|
590
|
+
const metadataMatches = (metadata) => {
|
|
591
|
+
if (!hasMemoryFilters) {
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
if (!metadata) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
if (filters.memoryType && metadata.memory_type !== filters.memoryType) {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
if (filters.topic && !includesIgnoreCase(metadata.topic, filters.topic)) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
if (filters.scope && !includesIgnoreCase(metadata.scope, filters.scope)) {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
};
|
|
608
|
+
const queryMatches = (source, content) => {
|
|
609
|
+
if (!keywords.length) {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
const haystack = `${source}\n${content}`.toLowerCase();
|
|
613
|
+
return keywords.some((keyword) => haystack.includes(keyword));
|
|
614
|
+
};
|
|
615
|
+
const items = [];
|
|
616
|
+
for (const group of groups) {
|
|
617
|
+
for (const source of group.files) {
|
|
618
|
+
const content = readFileSync(path.join(repo, source), "utf8");
|
|
619
|
+
const { metadata } = parseFrontmatter(content);
|
|
620
|
+
if (!sourceMatches(metadata) || !metadataMatches(metadata) || !queryMatches(source, content)) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
items.push({ source, source_type: group.source_type, priority: group.priority, content, metadata: contextMetadata(metadata) });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return items.sort((a, b) => a.priority - b.priority || a.source.localeCompare(b.source));
|
|
627
|
+
}
|
|
628
|
+
function renderContextMarkdown(items) {
|
|
629
|
+
const lines = ["# Project Atlas Context Pack", ""];
|
|
630
|
+
for (const item of items) {
|
|
631
|
+
lines.push(`## Source: \`${item.source}\``, `Type: ${item.source_type}`);
|
|
632
|
+
if (item.metadata?.memory_type)
|
|
633
|
+
lines.push(`Memory Type: ${item.metadata.memory_type}`);
|
|
634
|
+
if (item.metadata?.topic)
|
|
635
|
+
lines.push(`Topic: ${item.metadata.topic}`);
|
|
636
|
+
if (item.metadata?.scope)
|
|
637
|
+
lines.push(`Scope: ${item.metadata.scope}`);
|
|
638
|
+
lines.push("", item.content.trim(), "");
|
|
639
|
+
}
|
|
640
|
+
return lines.join("\n");
|
|
641
|
+
}
|
|
642
|
+
function contextMetadata(metadata) {
|
|
643
|
+
if (!metadata) {
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
const result = {};
|
|
647
|
+
if (metadata.memory_type)
|
|
648
|
+
result.memory_type = metadata.memory_type;
|
|
649
|
+
if (metadata.topic)
|
|
650
|
+
result.topic = metadata.topic;
|
|
651
|
+
if (metadata.scope)
|
|
652
|
+
result.scope = metadata.scope;
|
|
653
|
+
if (metadata.confidence !== undefined)
|
|
654
|
+
result.confidence = metadata.confidence;
|
|
655
|
+
if (metadata.owner)
|
|
656
|
+
result.owner = metadata.owner;
|
|
657
|
+
if (metadata.related_docs?.length)
|
|
658
|
+
result.related_docs = metadata.related_docs;
|
|
659
|
+
return Object.keys(result).length ? result : undefined;
|
|
660
|
+
}
|
|
661
|
+
function staleItems(repo) {
|
|
662
|
+
return knowledgeMarkdownFiles(repo)
|
|
663
|
+
.map((rel) => {
|
|
664
|
+
const content = readFileSync(path.join(repo, rel), "utf8");
|
|
665
|
+
const { metadata } = parseFrontmatter(content);
|
|
666
|
+
if (!metadata || metadata.source_files.length === 0) {
|
|
667
|
+
return {
|
|
668
|
+
path: rel,
|
|
669
|
+
status: "missing_metadata",
|
|
670
|
+
source_files: [],
|
|
671
|
+
details: ["frontmatter or source_files missing"],
|
|
672
|
+
suggestion: `Add project-atlas frontmatter or regenerate with project-atlas propose for ${rel}.`,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const details = [];
|
|
676
|
+
let status = "fresh";
|
|
677
|
+
for (const source of metadata.source_files) {
|
|
678
|
+
const current = repoFileHash(repo, source);
|
|
679
|
+
const expected = metadata.source_hashes[source];
|
|
680
|
+
if (current === "sha256:missing") {
|
|
681
|
+
status = "missing_source";
|
|
682
|
+
details.push(`${source} is missing`);
|
|
683
|
+
}
|
|
684
|
+
else if (!expected || expected !== current) {
|
|
685
|
+
if (status !== "missing_source")
|
|
686
|
+
status = "stale";
|
|
687
|
+
details.push(`${source} hash changed`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return { path: rel, status, source_files: metadata.source_files, details, suggestion: staleSuggestion(rel, status) };
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
function renderStaleMarkdown(items) {
|
|
694
|
+
return [
|
|
695
|
+
"# Project Atlas Stale Report",
|
|
696
|
+
"",
|
|
697
|
+
...items.map((item) => `- ${item.path}: ${item.status}${item.details.length ? ` (${item.details.join("; ")})` : ""}; Suggestion: ${item.suggestion}`),
|
|
698
|
+
"",
|
|
699
|
+
].join("\n");
|
|
700
|
+
}
|
|
701
|
+
function checkKnowledge(repo) {
|
|
702
|
+
const items = [];
|
|
703
|
+
const manifestPath = path.join(repo, "knowledge/manifest.json");
|
|
704
|
+
let requiredFiles = [];
|
|
705
|
+
if (!existsSync(manifestPath)) {
|
|
706
|
+
items.push(checkIssue("error", "missing_manifest", "knowledge/manifest.json", "knowledge manifest is missing.", "Run project-atlas init or restore knowledge/manifest.json."));
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
try {
|
|
710
|
+
const manifest = readJson(manifestPath);
|
|
711
|
+
if (Array.isArray(manifest.required_files)) {
|
|
712
|
+
requiredFiles = manifest.required_files.filter((item) => typeof item === "string");
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
items.push(checkIssue("error", "invalid_manifest", "knowledge/manifest.json", "required_files must be an array.", "Repair knowledge/manifest.json."));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
items.push(checkIssue("error", "invalid_manifest", "knowledge/manifest.json", "manifest must be valid JSON.", "Repair knowledge/manifest.json."));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
for (const rel of requiredFiles) {
|
|
723
|
+
if (!existsSync(path.join(repo, rel))) {
|
|
724
|
+
items.push(checkIssue("error", "missing_required_file", rel, "required knowledge file is missing.", "Restore the file or update required_files in knowledge/manifest.json."));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const topicPaths = new Map();
|
|
728
|
+
for (const rel of knowledgeMarkdownFiles(repo)) {
|
|
729
|
+
const abs = path.join(repo, rel);
|
|
730
|
+
const content = readFileSync(abs, "utf8");
|
|
731
|
+
const { metadata, body } = parseFrontmatter(content);
|
|
732
|
+
if (!body.trim() && !isScaffoldKnowledgeFile(rel)) {
|
|
733
|
+
items.push(checkIssue("error", "empty_document", rel, "knowledge document has no body content.", "Add content or remove the empty document."));
|
|
734
|
+
}
|
|
735
|
+
for (const link of markdownRelativeLinks(content)) {
|
|
736
|
+
const target = path.normalize(path.join(path.dirname(abs), link));
|
|
737
|
+
if (!existsSync(target)) {
|
|
738
|
+
items.push(checkIssue("error", "broken_link", rel, `relative link is broken: ${link}`, "Fix the link target or remove the link."));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!metadata || metadata.source_files.length === 0) {
|
|
742
|
+
if (!isScaffoldKnowledgeFile(rel)) {
|
|
743
|
+
items.push(checkIssue("error", "missing_metadata", rel, "frontmatter or source_files missing.", "Regenerate this file with project-atlas remember or project-atlas propose."));
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
for (const source of metadata.source_files) {
|
|
748
|
+
const current = repoFileHash(repo, source);
|
|
749
|
+
const expected = metadata.source_hashes[source];
|
|
750
|
+
if (current === "sha256:missing") {
|
|
751
|
+
items.push(checkIssue("error", "missing_source", rel, `source file is missing: ${source}`, "Restore the source file or refresh this memory with current evidence."));
|
|
752
|
+
}
|
|
753
|
+
else if (!expected || expected !== current) {
|
|
754
|
+
items.push(checkIssue("error", "stale_source", rel, `source hash changed: ${source}`, "Refresh this memory with project-atlas remember or project-atlas propose."));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (metadata.topic) {
|
|
758
|
+
const key = metadata.topic.toLowerCase();
|
|
759
|
+
topicPaths.set(key, [...(topicPaths.get(key) ?? []), rel]);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
for (const [topic, paths] of topicPaths.entries()) {
|
|
763
|
+
if (paths.length > 1) {
|
|
764
|
+
for (const rel of paths) {
|
|
765
|
+
items.push(checkIssue("warning", "duplicate_topic", rel, `topic appears in multiple files: ${topic}`, "Merge duplicated memories or use a more specific topic."));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const rel of globFiles(repo, "schema", [".schema.json"])) {
|
|
770
|
+
try {
|
|
771
|
+
JSON.parse(readFileSync(path.join(repo, rel), "utf8"));
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
items.push(checkIssue("error", "invalid_schema_json", rel, "schema file must be valid JSON.", "Repair or remove the invalid schema file."));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const ok = !items.some((item) => item.level === "error");
|
|
778
|
+
return { schema_version: "1.0", repo, ok, items };
|
|
779
|
+
}
|
|
780
|
+
function renderCheckMarkdown(result) {
|
|
781
|
+
const lines = ["# Project Atlas Check", "", `- ok: ${result.ok ? "yes" : "no"}`, `- issues: ${result.items.length}`, ""];
|
|
782
|
+
if (!result.items.length) {
|
|
783
|
+
lines.push("## Issues", "- none", "");
|
|
784
|
+
return lines.join("\n");
|
|
785
|
+
}
|
|
786
|
+
lines.push("## Issues");
|
|
787
|
+
for (const item of result.items) {
|
|
788
|
+
lines.push(`- ${item.level} ${item.rule_id} ${item.path}: ${item.message}; Suggestion: ${item.suggestion}`);
|
|
789
|
+
}
|
|
790
|
+
lines.push("");
|
|
791
|
+
return lines.join("\n");
|
|
792
|
+
}
|
|
793
|
+
function checkIssue(level, ruleId, pathValue, message, suggestion) {
|
|
794
|
+
return { level, rule_id: ruleId, path: pathValue, message, suggestion };
|
|
795
|
+
}
|
|
796
|
+
function markdownRelativeLinks(content) {
|
|
797
|
+
const links = [];
|
|
798
|
+
for (const match of content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) {
|
|
799
|
+
const raw = match[1].trim();
|
|
800
|
+
if (!raw || raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("/")) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
links.push(raw.split("#")[0]);
|
|
804
|
+
}
|
|
805
|
+
return links.filter(Boolean);
|
|
806
|
+
}
|
|
807
|
+
function createProposal(repo, inputData, reason, inheritSourceMetadata = false) {
|
|
808
|
+
const root = proposalRoot(repo);
|
|
809
|
+
ensureDir(root);
|
|
810
|
+
const proposalId = `kb-${timestamp()}-${Math.floor(Math.random() * 100000)}`;
|
|
811
|
+
const dir = path.join(root, proposalId);
|
|
812
|
+
ensureDir(dir);
|
|
813
|
+
const targetFiles = inputData.updates.map((update) => validateKnowledgeTarget(update.target));
|
|
814
|
+
const duplicateTarget = firstDuplicate(targetFiles);
|
|
815
|
+
if (duplicateTarget) {
|
|
816
|
+
throw new Error(`duplicate proposal target: ${duplicateTarget}`);
|
|
817
|
+
}
|
|
818
|
+
const inheritedSourceFiles = inheritSourceMetadata ? inheritedSourceFilesForTargets(repo, targetFiles) : [];
|
|
819
|
+
const sourceFiles = unique([...inheritedSourceFiles, ...(inputData.source_files ?? [])].filter(Boolean).map((source) => validateRepoRelativePath(source, "source_files item")));
|
|
820
|
+
const sourceHashes = Object.fromEntries(sourceFiles.map((source) => [source, repoFileHash(repo, source)]));
|
|
821
|
+
const sensitiveTargets = inputData.updates.filter((update) => hasSensitiveContent(update.content)).map((update) => validateKnowledgeTarget(update.target));
|
|
822
|
+
const status = sensitiveTargets.length ? "blocked_sensitive" : "proposed";
|
|
823
|
+
if (sensitiveTargets.length) {
|
|
824
|
+
writeJson(path.join(dir, "blocked-sensitive-summary.json"), {
|
|
825
|
+
schema_version: "1.0",
|
|
826
|
+
blocked_at: new Date().toISOString(),
|
|
827
|
+
items: sensitiveTargets.map((target) => ({ path: target, rule_id: "builtin.secret.generic", rule_category: "secret", action: "blocked_full_diff" })),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const operations = status === "blocked_sensitive"
|
|
831
|
+
? []
|
|
832
|
+
: inputData.updates.map((update) => {
|
|
833
|
+
const target = validateKnowledgeTarget(update.target);
|
|
834
|
+
const content = ensureKnowledgeFrontmatter(update.content, {
|
|
835
|
+
source_files: sourceFiles,
|
|
836
|
+
source_hashes: sourceHashes,
|
|
837
|
+
generated_by: "project-atlas",
|
|
838
|
+
review_status: "draft",
|
|
839
|
+
});
|
|
840
|
+
return { type: "replace_file", path: target, content, target_current_hash: repoFileHash(repo, target) };
|
|
841
|
+
});
|
|
842
|
+
const proposalBase = {
|
|
843
|
+
proposal_id: proposalId,
|
|
844
|
+
schema_version: "1.0",
|
|
845
|
+
base_commit: currentCommit(repo),
|
|
846
|
+
worktree_diff_hash: worktreeHash(repo),
|
|
847
|
+
source_files: sourceFiles,
|
|
848
|
+
source_hashes: sourceHashes,
|
|
849
|
+
target_files: targetFiles,
|
|
850
|
+
operations,
|
|
851
|
+
created_at: new Date().toISOString(),
|
|
852
|
+
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
853
|
+
reason,
|
|
854
|
+
external_evidence: inputData.external_evidence ?? [],
|
|
855
|
+
sensitive_scan_result: status === "blocked_sensitive" ? "blocked" : "passed",
|
|
856
|
+
proposal_status: status,
|
|
857
|
+
};
|
|
858
|
+
const proposalHash = sha256Text(JSON.stringify(proposalBase));
|
|
859
|
+
const proposal = { ...proposalBase, proposal_hash: proposalHash };
|
|
860
|
+
writeJson(path.join(dir, "proposal.json"), proposal);
|
|
861
|
+
writeFileSync(path.join(dir, "dry-run.diff"), status === "blocked_sensitive" ? "" : renderDryRunDiff(repo, operations), "utf8");
|
|
862
|
+
writeEvidence(repo, proposalId, status, proposal.worktree_diff_hash, proposal.proposal_hash, "");
|
|
863
|
+
return proposal;
|
|
864
|
+
}
|
|
865
|
+
function loadUpdateInput(repo, target, contentFile, updatesFile) {
|
|
866
|
+
if (updatesFile) {
|
|
867
|
+
const parsed = readJson(path.resolve(repo, updatesFile));
|
|
868
|
+
if (!Array.isArray(parsed.updates) || parsed.updates.length === 0) {
|
|
869
|
+
throw new Error("updates-file must contain a non-empty updates array.");
|
|
870
|
+
}
|
|
871
|
+
return {
|
|
872
|
+
source_files: validateSourceFiles(parsed.source_files ?? []),
|
|
873
|
+
external_evidence: validateExternalEvidenceItems(parsed.external_evidence ?? []),
|
|
874
|
+
updates: parsed.updates,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
if (!target || !contentFile) {
|
|
878
|
+
throw new Error("provide --updates-file or --target with --content-file");
|
|
879
|
+
}
|
|
880
|
+
return { source_files: [], external_evidence: [], updates: [{ target, content: readFileSync(path.resolve(repo, contentFile), "utf8") }] };
|
|
881
|
+
}
|
|
882
|
+
function loadMemoryCandidate(repo, candidateFile) {
|
|
883
|
+
let parsed;
|
|
884
|
+
try {
|
|
885
|
+
parsed = JSON.parse(readFileSync(path.resolve(repo, candidateFile), "utf8"));
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
throw new Error("memory candidate file must be valid JSON.");
|
|
889
|
+
}
|
|
890
|
+
if (!isRecord(parsed)) {
|
|
891
|
+
throw new Error("memory candidate file must contain a JSON object.");
|
|
892
|
+
}
|
|
893
|
+
if (parsed.schema_version !== "1.0") {
|
|
894
|
+
throw new Error("memory candidate schema_version must be 1.0.");
|
|
895
|
+
}
|
|
896
|
+
if (!Array.isArray(parsed.source_files) || parsed.source_files.length === 0) {
|
|
897
|
+
throw new Error("memory candidate source_files must be a non-empty array.");
|
|
898
|
+
}
|
|
899
|
+
const sourceFiles = parsed.source_files.map((source) => {
|
|
900
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
901
|
+
throw new Error("memory candidate source_files items must be strings.");
|
|
902
|
+
}
|
|
903
|
+
return validateRepoRelativePath(source, "source_files item");
|
|
904
|
+
});
|
|
905
|
+
if (!Array.isArray(parsed.memories) || parsed.memories.length === 0) {
|
|
906
|
+
throw new Error("memory candidate memories must be a non-empty array.");
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
schema_version: "1.0",
|
|
910
|
+
source_files: sourceFiles,
|
|
911
|
+
memories: parsed.memories.map((raw) => validateMemoryCandidateItem(raw)),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function validateMemoryCandidateItem(raw) {
|
|
915
|
+
if (!isRecord(raw)) {
|
|
916
|
+
throw new Error("memory item must be an object.");
|
|
917
|
+
}
|
|
918
|
+
const target = validateKnowledgeTarget(requiredMemoryString(raw, "target"));
|
|
919
|
+
const memoryType = memoryTypeValue(requiredMemoryString(raw, "memory_type"), "memory item memory_type");
|
|
920
|
+
const topic = validateFrontmatterScalar(requiredMemoryString(raw, "topic"), "memory item topic");
|
|
921
|
+
const scope = validateFrontmatterScalar(requiredMemoryString(raw, "scope"), "memory item scope");
|
|
922
|
+
const summary = validateFrontmatterScalar(requiredMemoryString(raw, "summary"), "memory item summary");
|
|
923
|
+
const body = requiredMemoryString(raw, "body");
|
|
924
|
+
const confidence = raw.confidence;
|
|
925
|
+
if (typeof confidence !== "number" || confidence < 0 || confidence > 1) {
|
|
926
|
+
throw new Error("memory item confidence must be a number between 0 and 1.");
|
|
927
|
+
}
|
|
928
|
+
const item = { target, memory_type: memoryType, topic, scope, confidence, summary, body };
|
|
929
|
+
if (raw.owner !== undefined) {
|
|
930
|
+
if (typeof raw.owner !== "string") {
|
|
931
|
+
throw new Error("memory item owner must be a string.");
|
|
932
|
+
}
|
|
933
|
+
const owner = validateFrontmatterScalar(raw.owner, "memory item owner");
|
|
934
|
+
if (owner)
|
|
935
|
+
item.owner = owner;
|
|
936
|
+
}
|
|
937
|
+
if (raw.related_docs !== undefined) {
|
|
938
|
+
if (!Array.isArray(raw.related_docs)) {
|
|
939
|
+
throw new Error("memory item related_docs must be an array.");
|
|
940
|
+
}
|
|
941
|
+
item.related_docs = raw.related_docs.map((doc) => {
|
|
942
|
+
if (typeof doc !== "string" || !doc.trim()) {
|
|
943
|
+
throw new Error("memory item related_docs items must be strings.");
|
|
944
|
+
}
|
|
945
|
+
return validateRepoRelativePath(doc, "memory item related_docs item");
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
return item;
|
|
949
|
+
}
|
|
950
|
+
function memoryCandidateToUpdateInput(repo, candidate, replaceExisting) {
|
|
951
|
+
const sourceHashes = Object.fromEntries(candidate.source_files.map((source) => [source, repoFileHash(repo, source)]));
|
|
952
|
+
const updates = candidate.memories.map((memory) => {
|
|
953
|
+
if (!replaceExisting && existsSync(path.join(repo, memory.target))) {
|
|
954
|
+
throw new Error(`memory target already exists: ${memory.target}. Use --replace-existing to replace it.`);
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
target: memory.target,
|
|
958
|
+
content: buildMemoryContent(memory, candidate.source_files, sourceHashes),
|
|
959
|
+
};
|
|
960
|
+
});
|
|
961
|
+
return { source_files: candidate.source_files, external_evidence: [], updates };
|
|
962
|
+
}
|
|
963
|
+
function validateSourceFiles(value) {
|
|
964
|
+
if (!Array.isArray(value)) {
|
|
965
|
+
throw new Error("source_files must be an array.");
|
|
966
|
+
}
|
|
967
|
+
return value.map((source) => {
|
|
968
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
969
|
+
throw new Error("source_files items must be strings.");
|
|
970
|
+
}
|
|
971
|
+
return validateRepoRelativePath(source, "source_files item");
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function buildMemoryContent(memory, sourceFiles, sourceHashes) {
|
|
975
|
+
const body = memory.body.trim();
|
|
976
|
+
const content = body.startsWith("#") ? `${body}\n` : `# ${memory.summary}\n\n${body}\n`;
|
|
977
|
+
return `${buildFrontmatter({
|
|
978
|
+
source_files: sourceFiles,
|
|
979
|
+
source_hashes: sourceHashes,
|
|
980
|
+
generated_by: "project-atlas",
|
|
981
|
+
review_status: "draft",
|
|
982
|
+
memory_type: memory.memory_type,
|
|
983
|
+
topic: memory.topic,
|
|
984
|
+
scope: memory.scope,
|
|
985
|
+
confidence: memory.confidence,
|
|
986
|
+
owner: memory.owner,
|
|
987
|
+
related_docs: memory.related_docs,
|
|
988
|
+
})}${content}`;
|
|
989
|
+
}
|
|
990
|
+
function loadExternalEvidence(repo, evidenceFile) {
|
|
991
|
+
if (!evidenceFile) {
|
|
992
|
+
return [];
|
|
993
|
+
}
|
|
994
|
+
let parsed;
|
|
995
|
+
try {
|
|
996
|
+
parsed = JSON.parse(readFileSync(path.resolve(repo, evidenceFile), "utf8"));
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
throw new Error("external evidence file must be valid JSON.");
|
|
1000
|
+
}
|
|
1001
|
+
if (!isRecord(parsed)) {
|
|
1002
|
+
throw new Error("external evidence file must contain a JSON object.");
|
|
1003
|
+
}
|
|
1004
|
+
if (parsed.schema_version !== "1.0") {
|
|
1005
|
+
throw new Error("external evidence schema_version must be 1.0.");
|
|
1006
|
+
}
|
|
1007
|
+
return validateExternalEvidenceItems(parsed.external_evidence);
|
|
1008
|
+
}
|
|
1009
|
+
function validateExternalEvidenceItems(value) {
|
|
1010
|
+
if (!Array.isArray(value)) {
|
|
1011
|
+
throw new Error("external_evidence must be an array.");
|
|
1012
|
+
}
|
|
1013
|
+
return value.map((raw) => {
|
|
1014
|
+
if (!isRecord(raw)) {
|
|
1015
|
+
throw new Error("external_evidence item must be an object.");
|
|
1016
|
+
}
|
|
1017
|
+
const source = requiredString(raw, "source");
|
|
1018
|
+
const sourceType = requiredString(raw, "source_type");
|
|
1019
|
+
const itemPath = requiredString(raw, "path");
|
|
1020
|
+
const item = {
|
|
1021
|
+
source,
|
|
1022
|
+
source_type: sourceType,
|
|
1023
|
+
path: normalizeRepoPath(itemPath),
|
|
1024
|
+
};
|
|
1025
|
+
for (const field of ["symbol", "summary", "locator"]) {
|
|
1026
|
+
const valueForField = raw[field];
|
|
1027
|
+
if (valueForField !== undefined) {
|
|
1028
|
+
if (typeof valueForField !== "string") {
|
|
1029
|
+
throw new Error(`external_evidence item ${field} must be a string.`);
|
|
1030
|
+
}
|
|
1031
|
+
item[field] = valueForField;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (raw.confidence !== undefined) {
|
|
1035
|
+
if (typeof raw.confidence !== "number" || raw.confidence < 0 || raw.confidence > 1) {
|
|
1036
|
+
throw new Error("external_evidence item confidence must be a number between 0 and 1.");
|
|
1037
|
+
}
|
|
1038
|
+
item.confidence = raw.confidence;
|
|
1039
|
+
}
|
|
1040
|
+
return item;
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
function requiredString(record, field) {
|
|
1044
|
+
const value = record[field];
|
|
1045
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1046
|
+
throw new Error(`external_evidence item ${field} is required.`);
|
|
1047
|
+
}
|
|
1048
|
+
return value.trim();
|
|
1049
|
+
}
|
|
1050
|
+
function requiredMemoryString(record, field) {
|
|
1051
|
+
const value = record[field];
|
|
1052
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1053
|
+
throw new Error(`memory item ${field} is required.`);
|
|
1054
|
+
}
|
|
1055
|
+
return value.trim();
|
|
1056
|
+
}
|
|
1057
|
+
function isRecord(value) {
|
|
1058
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1059
|
+
}
|
|
1060
|
+
function writeEvidence(repo, proposalId, status, worktree, proposalHash, appliedHash) {
|
|
1061
|
+
const dir = path.join(proposalRoot(repo), proposalId);
|
|
1062
|
+
const latest = {
|
|
1063
|
+
proposal_id: proposalId,
|
|
1064
|
+
proposal_status: status,
|
|
1065
|
+
worktree_diff_hash: worktree,
|
|
1066
|
+
proposal_hash: proposalHash,
|
|
1067
|
+
applied_hash: appliedHash,
|
|
1068
|
+
updated_at: new Date().toISOString(),
|
|
1069
|
+
};
|
|
1070
|
+
const trigger = {
|
|
1071
|
+
schema_version: "1.0",
|
|
1072
|
+
proposal_id: proposalId,
|
|
1073
|
+
proposal_hash: proposalHash,
|
|
1074
|
+
worktree_diff_hash: worktree,
|
|
1075
|
+
applied_hash: appliedHash,
|
|
1076
|
+
needs_knowledge_update: true,
|
|
1077
|
+
proposal_status: status,
|
|
1078
|
+
updated_at: latest.updated_at,
|
|
1079
|
+
};
|
|
1080
|
+
writeJson(path.join(proposalRoot(repo), "latest.json"), latest);
|
|
1081
|
+
writeJson(path.join(dir, "trigger-result.json"), trigger);
|
|
1082
|
+
}
|
|
1083
|
+
function assertProposalStillFresh(repo, proposal) {
|
|
1084
|
+
const commit = currentCommit(repo);
|
|
1085
|
+
if (commit && proposal.base_commit && commit !== proposal.base_commit) {
|
|
1086
|
+
throw new Error(`base commit changed since proposal was created: ${proposal.base_commit} -> ${commit}`);
|
|
1087
|
+
}
|
|
1088
|
+
const sourceHashes = proposal.source_hashes ?? {};
|
|
1089
|
+
for (const source of proposal.source_files) {
|
|
1090
|
+
const expected = sourceHashes[source];
|
|
1091
|
+
if (!expected) {
|
|
1092
|
+
throw new Error(`proposal is missing source hash snapshot for ${source}; regenerate proposal.`);
|
|
1093
|
+
}
|
|
1094
|
+
const current = repoFileHash(repo, source);
|
|
1095
|
+
if (current !== expected) {
|
|
1096
|
+
throw new Error(`source changed since proposal was created: ${source}`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
for (const operation of proposal.operations) {
|
|
1100
|
+
const current = repoFileHash(repo, operation.path);
|
|
1101
|
+
if (current !== operation.target_current_hash) {
|
|
1102
|
+
throw new Error(`target changed since proposal was created: ${operation.path}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function latestProposalId(repo) {
|
|
1107
|
+
const latestPath = path.join(proposalRoot(repo), "latest.json");
|
|
1108
|
+
return existsSync(latestPath) ? readJson(latestPath).proposal_id : "";
|
|
1109
|
+
}
|
|
1110
|
+
function renderDryRunDiff(repo, operations) {
|
|
1111
|
+
return operations
|
|
1112
|
+
.map((operation) => {
|
|
1113
|
+
const oldContent = existsSync(path.join(repo, operation.path)) ? readFileSync(path.join(repo, operation.path), "utf8") : "";
|
|
1114
|
+
return [`--- ${operation.path}`, `+++ ${operation.path}`, `@@`, `- ${oldContent.trim().split(/\r?\n/).slice(0, 12).join("\n- ")}`, `+ ${operation.content.trim().split(/\r?\n/).slice(0, 12).join("\n+ ")}`, ""].join("\n");
|
|
1115
|
+
})
|
|
1116
|
+
.join("\n");
|
|
1117
|
+
}
|
|
1118
|
+
function dryRunSummaryLines(diffPath, targetFiles) {
|
|
1119
|
+
if (!existsSync(diffPath)) {
|
|
1120
|
+
return ["- dry_run_diff: missing"];
|
|
1121
|
+
}
|
|
1122
|
+
const text = readFileSync(diffPath, "utf8");
|
|
1123
|
+
let additions = 0;
|
|
1124
|
+
let deletions = 0;
|
|
1125
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1126
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
if (line.startsWith("+"))
|
|
1130
|
+
additions++;
|
|
1131
|
+
if (line.startsWith("-"))
|
|
1132
|
+
deletions++;
|
|
1133
|
+
}
|
|
1134
|
+
return [`- target_files: ${targetFiles.length ? targetFiles.join(", ") : "none"}`, `- changed_lines: +${additions} -${deletions}`];
|
|
1135
|
+
}
|
|
1136
|
+
function reviewDecision(proposal, risk) {
|
|
1137
|
+
if (proposal.proposal_status === "blocked_sensitive" || proposal.sensitive_scan_result === "blocked") {
|
|
1138
|
+
return "blocked by sensitive content; do not apply.";
|
|
1139
|
+
}
|
|
1140
|
+
if (risk.hasMissingSource) {
|
|
1141
|
+
return "missing source files must be restored or reviewed before apply.";
|
|
1142
|
+
}
|
|
1143
|
+
if (risk.hasMissingMetadata) {
|
|
1144
|
+
return "knowledge metadata is incomplete; regenerate or repair docs before apply.";
|
|
1145
|
+
}
|
|
1146
|
+
if (risk.hasStale) {
|
|
1147
|
+
return "review stale knowledge before apply.";
|
|
1148
|
+
}
|
|
1149
|
+
if (proposal.proposal_status === "proposed") {
|
|
1150
|
+
return "review dry-run.diff, then apply if content is correct.";
|
|
1151
|
+
}
|
|
1152
|
+
return `no apply action for status ${proposal.proposal_status}.`;
|
|
1153
|
+
}
|
|
1154
|
+
function nextReviewStep(proposal, hasKnowledgeRisk) {
|
|
1155
|
+
if (proposal.proposal_status !== "proposed") {
|
|
1156
|
+
return `- No apply command is available for status ${proposal.proposal_status}.`;
|
|
1157
|
+
}
|
|
1158
|
+
if (hasKnowledgeRisk) {
|
|
1159
|
+
return "- Resolve stale, missing source, or missing metadata items, then regenerate the proposal.";
|
|
1160
|
+
}
|
|
1161
|
+
return "- No apply command is available until Apply Safety is clear.";
|
|
1162
|
+
}
|
|
1163
|
+
function staleSuggestion(pathValue, status) {
|
|
1164
|
+
if (status === "fresh") {
|
|
1165
|
+
return "No action needed.";
|
|
1166
|
+
}
|
|
1167
|
+
if (status === "missing_source") {
|
|
1168
|
+
return `Check missing source file before refreshing ${pathValue}.`;
|
|
1169
|
+
}
|
|
1170
|
+
if (status === "missing_metadata") {
|
|
1171
|
+
return `Add project-atlas frontmatter or regenerate with project-atlas propose for ${pathValue}.`;
|
|
1172
|
+
}
|
|
1173
|
+
return `Run project-atlas propose with refreshed content for ${pathValue}.`;
|
|
1174
|
+
}
|
|
1175
|
+
function isScaffoldKnowledgeFile(pathValue) {
|
|
1176
|
+
return (pathValue === "knowledge/README.md" ||
|
|
1177
|
+
pathValue === "knowledge/index.md" ||
|
|
1178
|
+
pathValue === "knowledge/glossary.md" ||
|
|
1179
|
+
/^knowledge\/(domains|workflows|contracts|integrations|quality|decisions)\/README\.md$/.test(pathValue));
|
|
1180
|
+
}
|
|
1181
|
+
function inheritedSourceFilesForTargets(repo, targetFiles) {
|
|
1182
|
+
const sources = [];
|
|
1183
|
+
for (const target of targetFiles) {
|
|
1184
|
+
const targetAbs = path.join(repo, target);
|
|
1185
|
+
if (!existsSync(targetAbs)) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const { metadata } = parseFrontmatter(readFileSync(targetAbs, "utf8"));
|
|
1189
|
+
if (metadata) {
|
|
1190
|
+
sources.push(...metadata.source_files);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return unique(sources.map(normalizeRepoPath));
|
|
1194
|
+
}
|
|
1195
|
+
function hasSensitiveContent(content) {
|
|
1196
|
+
return [
|
|
1197
|
+
/password\s*[:=]\s*\S{3,}/i,
|
|
1198
|
+
/token\s*[:=]\s*\S{8,}/i,
|
|
1199
|
+
/accessKey(Id|Secret)?\s*[:=]\s*\S{8,}/i,
|
|
1200
|
+
/secret\s*[:=]\s*\S{8,}/i,
|
|
1201
|
+
].some((rule) => rule.test(content));
|
|
1202
|
+
}
|
|
1203
|
+
function globFiles(repo, dir, suffixes) {
|
|
1204
|
+
const abs = path.join(repo, dir);
|
|
1205
|
+
if (!existsSync(abs)) {
|
|
1206
|
+
return [];
|
|
1207
|
+
}
|
|
1208
|
+
const output = [];
|
|
1209
|
+
const visit = (currentAbs, currentRel) => {
|
|
1210
|
+
for (const entry of readdirSync(currentAbs, { withFileTypes: true })) {
|
|
1211
|
+
const rel = path.posix.join(currentRel, entry.name);
|
|
1212
|
+
const childAbs = path.join(repo, rel);
|
|
1213
|
+
if (entry.isDirectory()) {
|
|
1214
|
+
visit(childAbs, rel);
|
|
1215
|
+
}
|
|
1216
|
+
else if (suffixes.some((suffix) => entry.name.endsWith(suffix))) {
|
|
1217
|
+
output.push(rel);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
visit(abs, dir);
|
|
1222
|
+
return output.sort();
|
|
1223
|
+
}
|
|
1224
|
+
function knowledgeMarkdownFiles(repo) {
|
|
1225
|
+
return globFiles(repo, "knowledge", [".md"]).filter((rel) => !rel.startsWith("knowledge/logs/") && !rel.startsWith("knowledge/assets/"));
|
|
1226
|
+
}
|
|
1227
|
+
function listOrNone(items) {
|
|
1228
|
+
return items.length ? items.map((item) => `- ${item}`) : ["- none"];
|
|
1229
|
+
}
|
|
1230
|
+
function externalEvidenceLines(items) {
|
|
1231
|
+
if (!items.length) {
|
|
1232
|
+
return ["- none"];
|
|
1233
|
+
}
|
|
1234
|
+
return items.map((item) => {
|
|
1235
|
+
const details = [`${item.source} (${item.source_type})`, item.path];
|
|
1236
|
+
if (item.symbol)
|
|
1237
|
+
details.push(`symbol: ${item.symbol}`);
|
|
1238
|
+
if (item.summary)
|
|
1239
|
+
details.push(item.summary);
|
|
1240
|
+
if (item.locator)
|
|
1241
|
+
details.push(`locator: ${item.locator}`);
|
|
1242
|
+
if (item.confidence !== undefined)
|
|
1243
|
+
details.push(`confidence: ${item.confidence}`);
|
|
1244
|
+
return `- ${details.join(" | ")}`;
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
function truncate(value, budget) {
|
|
1248
|
+
if (value.length <= budget) {
|
|
1249
|
+
return { text: value, budget_used: value.length, truncated: false };
|
|
1250
|
+
}
|
|
1251
|
+
const marker = "\n...(truncated)";
|
|
1252
|
+
const text = budget <= marker.length ? value.slice(0, budget) : `${value.slice(0, budget - marker.length)}${marker}`;
|
|
1253
|
+
return { text, budget_used: text.length, truncated: true };
|
|
1254
|
+
}
|
|
1255
|
+
function unique(values) {
|
|
1256
|
+
return [...new Set(values)];
|
|
1257
|
+
}
|
|
1258
|
+
function firstDuplicate(values) {
|
|
1259
|
+
const seen = new Set();
|
|
1260
|
+
for (const value of values) {
|
|
1261
|
+
if (seen.has(value)) {
|
|
1262
|
+
return value;
|
|
1263
|
+
}
|
|
1264
|
+
seen.add(value);
|
|
1265
|
+
}
|
|
1266
|
+
return "";
|
|
1267
|
+
}
|
|
1268
|
+
function normalizeRepoPath(value) {
|
|
1269
|
+
return value.replace(/\\/g, "/").split(path.sep).join("/");
|
|
1270
|
+
}
|
|
1271
|
+
function validateRepoRelativePath(value, label) {
|
|
1272
|
+
const normalizedSeparators = normalizeRepoPath(value);
|
|
1273
|
+
if (/[\r\n\0]/.test(normalizedSeparators)) {
|
|
1274
|
+
throw new Error(`${label} must be a repository-relative path without line breaks.`);
|
|
1275
|
+
}
|
|
1276
|
+
const raw = normalizedSeparators.trim();
|
|
1277
|
+
if (!raw) {
|
|
1278
|
+
throw new Error(`${label} must be a repository-relative path without line breaks.`);
|
|
1279
|
+
}
|
|
1280
|
+
if (/^[A-Za-z]:/.test(raw)) {
|
|
1281
|
+
throw new Error(`${label} must be a repository-relative path.`);
|
|
1282
|
+
}
|
|
1283
|
+
if (path.posix.isAbsolute(raw) || raw.split("/").includes("..")) {
|
|
1284
|
+
throw new Error(`${label} must stay inside the repository.`);
|
|
1285
|
+
}
|
|
1286
|
+
const normalized = path.posix.normalize(raw);
|
|
1287
|
+
if (!normalized || normalized === "." || path.posix.isAbsolute(normalized) || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
1288
|
+
throw new Error(`${label} must stay inside the repository.`);
|
|
1289
|
+
}
|
|
1290
|
+
const root = normalized.split("/")[0];
|
|
1291
|
+
if (root === ".git" || root === ".project-atlas" || root === ".code-review-graph") {
|
|
1292
|
+
throw new Error(`${label} cannot reference local evidence or Git metadata paths.`);
|
|
1293
|
+
}
|
|
1294
|
+
return normalized;
|
|
1295
|
+
}
|
|
1296
|
+
function validateFrontmatterScalar(value, label) {
|
|
1297
|
+
if (/[\r\n\0]/.test(value)) {
|
|
1298
|
+
throw new Error(`${label} must not contain line breaks.`);
|
|
1299
|
+
}
|
|
1300
|
+
return value.trim();
|
|
1301
|
+
}
|
|
1302
|
+
function templateFlag(flags) {
|
|
1303
|
+
const value = stringFlag(flags, "template", "generic-service");
|
|
1304
|
+
if (value === "generic-service" || value === "java-backend" || value === "frontend-app") {
|
|
1305
|
+
return value;
|
|
1306
|
+
}
|
|
1307
|
+
throw usageError("init", "--template must be generic-service, java-backend, or frontend-app");
|
|
1308
|
+
}
|
|
1309
|
+
function timestamp() {
|
|
1310
|
+
const date = new Date();
|
|
1311
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
1312
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
1313
|
+
}
|
|
1314
|
+
function parseArgs(argv) {
|
|
1315
|
+
const [command = "", ...rest] = argv;
|
|
1316
|
+
const flags = {};
|
|
1317
|
+
for (let index = 0; index < rest.length; index++) {
|
|
1318
|
+
const item = rest[index];
|
|
1319
|
+
if (!item.startsWith("--")) {
|
|
1320
|
+
throw usageError(command, `Unexpected argument: ${item}`);
|
|
1321
|
+
}
|
|
1322
|
+
const key = item.slice(2);
|
|
1323
|
+
const next = rest[index + 1];
|
|
1324
|
+
if (!next || next.startsWith("--")) {
|
|
1325
|
+
flags[key] = true;
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
flags[key] = next;
|
|
1329
|
+
index++;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return { command, flags };
|
|
1333
|
+
}
|
|
1334
|
+
function validateFlags(command, flags) {
|
|
1335
|
+
const allowed = new Set([...(commandOptions[command] ?? []), "help", "h"]);
|
|
1336
|
+
const booleanFlags = new Set([...(booleanOptions[command] ?? []), "help", "h"]);
|
|
1337
|
+
for (const key of Object.keys(flags)) {
|
|
1338
|
+
if (!allowed.has(key)) {
|
|
1339
|
+
throw usageError(command, `Unknown option: --${key}`);
|
|
1340
|
+
}
|
|
1341
|
+
if (booleanFlags.has(key) && typeof flags[key] === "string") {
|
|
1342
|
+
throw usageError(command, `--${key} does not take a value`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
function stringFlag(flags, key, fallback) {
|
|
1347
|
+
const value = flags[key];
|
|
1348
|
+
return typeof value === "string" ? value : fallback;
|
|
1349
|
+
}
|
|
1350
|
+
function optionalStringFlag(flags, key) {
|
|
1351
|
+
const value = flags[key];
|
|
1352
|
+
return typeof value === "string" ? value : undefined;
|
|
1353
|
+
}
|
|
1354
|
+
function numberFlag(flags, key, fallback, command = "") {
|
|
1355
|
+
const value = flags[key];
|
|
1356
|
+
if (typeof value !== "string") {
|
|
1357
|
+
return fallback;
|
|
1358
|
+
}
|
|
1359
|
+
const parsed = Number(value);
|
|
1360
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1361
|
+
throw usageError(command, `--${key} must be a positive number`);
|
|
1362
|
+
}
|
|
1363
|
+
return parsed;
|
|
1364
|
+
}
|
|
1365
|
+
function formatFlag(flags, command) {
|
|
1366
|
+
const format = stringFlag(flags, "format", "markdown");
|
|
1367
|
+
if (format !== "markdown" && format !== "json") {
|
|
1368
|
+
throw usageError(command, "--format must be markdown or json");
|
|
1369
|
+
}
|
|
1370
|
+
return format;
|
|
1371
|
+
}
|
|
1372
|
+
function optionalMemoryTypeFlag(flags, command) {
|
|
1373
|
+
const value = optionalStringFlag(flags, "memory-type");
|
|
1374
|
+
return value ? memoryTypeValue(value, "--memory-type", command) : undefined;
|
|
1375
|
+
}
|
|
1376
|
+
function memoryTypeValue(value, label, command) {
|
|
1377
|
+
if (value === "decision" || value === "experience" || value === "project_fact") {
|
|
1378
|
+
return value;
|
|
1379
|
+
}
|
|
1380
|
+
const message = `${label} must be decision, experience, or project_fact`;
|
|
1381
|
+
if (command) {
|
|
1382
|
+
throw usageError(command, message);
|
|
1383
|
+
}
|
|
1384
|
+
throw new Error(message);
|
|
1385
|
+
}
|
|
1386
|
+
function includesIgnoreCase(value, query) {
|
|
1387
|
+
return Boolean(value?.toLowerCase().includes(query.toLowerCase()));
|
|
1388
|
+
}
|
|
1389
|
+
function usageError(command, message) {
|
|
1390
|
+
const help = commandHelp[command];
|
|
1391
|
+
if (!help) {
|
|
1392
|
+
return new Error(`${message}\n\nRun \`project-atlas --help\` to see available commands.`);
|
|
1393
|
+
}
|
|
1394
|
+
return new Error(`${message}\n\n${help}`);
|
|
1395
|
+
}
|