@nomos-arc/arc 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/.claude/settings.local.json +10 -0
- package/.nomos-config.json +5 -0
- package/CLAUDE.md +108 -0
- package/LICENSE +190 -0
- package/README.md +569 -0
- package/dist/cli.js +21120 -0
- package/docs/auth/googel_plan.yaml +1093 -0
- package/docs/auth/google_task.md +235 -0
- package/docs/auth/hardened_blueprint.yaml +1658 -0
- package/docs/auth/red_team_report.yaml +336 -0
- package/docs/auth/session_state.yaml +162 -0
- package/docs/certificate/cer_enhance_plan.md +605 -0
- package/docs/certificate/certificate_report.md +338 -0
- package/docs/dev_overview.md +419 -0
- package/docs/feature_assessment.md +156 -0
- package/docs/how_it_works.md +78 -0
- package/docs/infrastructure/map.md +867 -0
- package/docs/init/master_plan.md +3581 -0
- package/docs/init/red_team_report.md +215 -0
- package/docs/init/report_phase_1a.md +304 -0
- package/docs/integrity-gate/enhance_drift.md +703 -0
- package/docs/integrity-gate/overview.md +108 -0
- package/docs/management/manger-task.md +99 -0
- package/docs/management/scafffold.md +76 -0
- package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
- package/docs/map/RED_TEAM_REPORT.md +159 -0
- package/docs/map/map_task.md +147 -0
- package/docs/map/semantic_graph_task.md +792 -0
- package/docs/map/semantic_master_plan.md +705 -0
- package/docs/phase7/TEAM_RED.md +249 -0
- package/docs/phase7/plan.md +1682 -0
- package/docs/phase7/task.md +275 -0
- package/docs/prompts/USAGE.md +312 -0
- package/docs/prompts/architect.md +165 -0
- package/docs/prompts/executer.md +190 -0
- package/docs/prompts/hardener.md +190 -0
- package/docs/prompts/red_team.md +146 -0
- package/docs/verification/goveranance-overview.md +396 -0
- package/docs/verification/governance-overview.md +245 -0
- package/docs/verification/verification-arc-ar.md +560 -0
- package/docs/verification/verification-architecture.md +560 -0
- package/docs/very_next.md +52 -0
- package/docs/whitepaper.md +89 -0
- package/overview.md +1469 -0
- package/package.json +63 -0
- package/src/adapters/__tests__/git.test.ts +296 -0
- package/src/adapters/__tests__/stdio.test.ts +70 -0
- package/src/adapters/git.ts +226 -0
- package/src/adapters/pty.ts +159 -0
- package/src/adapters/stdio.ts +113 -0
- package/src/cli.ts +83 -0
- package/src/commands/apply.ts +47 -0
- package/src/commands/auth.ts +301 -0
- package/src/commands/certificate.ts +89 -0
- package/src/commands/discard.ts +24 -0
- package/src/commands/drift.ts +116 -0
- package/src/commands/index.ts +78 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/list.ts +75 -0
- package/src/commands/map.ts +55 -0
- package/src/commands/plan.ts +30 -0
- package/src/commands/review.ts +58 -0
- package/src/commands/run.ts +63 -0
- package/src/commands/search.ts +147 -0
- package/src/commands/show.ts +63 -0
- package/src/commands/status.ts +59 -0
- package/src/core/__tests__/budget.test.ts +213 -0
- package/src/core/__tests__/certificate.test.ts +385 -0
- package/src/core/__tests__/config.test.ts +191 -0
- package/src/core/__tests__/preflight.test.ts +24 -0
- package/src/core/__tests__/prompt.test.ts +358 -0
- package/src/core/__tests__/review.test.ts +161 -0
- package/src/core/__tests__/state.test.ts +362 -0
- package/src/core/auth/__tests__/manager.test.ts +166 -0
- package/src/core/auth/__tests__/server.test.ts +220 -0
- package/src/core/auth/gcp-projects.ts +160 -0
- package/src/core/auth/manager.ts +114 -0
- package/src/core/auth/server.ts +141 -0
- package/src/core/budget.ts +119 -0
- package/src/core/certificate.ts +502 -0
- package/src/core/config.ts +212 -0
- package/src/core/errors.ts +54 -0
- package/src/core/factory.ts +49 -0
- package/src/core/graph/__tests__/builder.test.ts +272 -0
- package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
- package/src/core/graph/__tests__/enricher.test.ts +299 -0
- package/src/core/graph/__tests__/parser.test.ts +200 -0
- package/src/core/graph/__tests__/pipeline.test.ts +202 -0
- package/src/core/graph/__tests__/renderer.test.ts +128 -0
- package/src/core/graph/__tests__/resolver.test.ts +185 -0
- package/src/core/graph/__tests__/scanner.test.ts +231 -0
- package/src/core/graph/__tests__/show.test.ts +134 -0
- package/src/core/graph/builder.ts +303 -0
- package/src/core/graph/constraints.ts +94 -0
- package/src/core/graph/contract-writer.ts +93 -0
- package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
- package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
- package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
- package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
- package/src/core/graph/drift/classifier.ts +165 -0
- package/src/core/graph/drift/comparator.ts +205 -0
- package/src/core/graph/drift/reporter.ts +77 -0
- package/src/core/graph/enricher.ts +251 -0
- package/src/core/graph/grammar-paths.ts +30 -0
- package/src/core/graph/html-template.ts +493 -0
- package/src/core/graph/map-schema.ts +137 -0
- package/src/core/graph/parser.ts +336 -0
- package/src/core/graph/pipeline.ts +209 -0
- package/src/core/graph/renderer.ts +92 -0
- package/src/core/graph/resolver.ts +195 -0
- package/src/core/graph/scanner.ts +145 -0
- package/src/core/logger.ts +46 -0
- package/src/core/orchestrator.ts +792 -0
- package/src/core/plan-file-manager.ts +66 -0
- package/src/core/preflight.ts +64 -0
- package/src/core/prompt.ts +173 -0
- package/src/core/review.ts +95 -0
- package/src/core/state.ts +294 -0
- package/src/core/worktree-coordinator.ts +77 -0
- package/src/search/__tests__/chunk-extractor.test.ts +339 -0
- package/src/search/__tests__/embedder-auth.test.ts +124 -0
- package/src/search/__tests__/embedder.test.ts +267 -0
- package/src/search/__tests__/graph-enricher.test.ts +178 -0
- package/src/search/__tests__/indexer.test.ts +518 -0
- package/src/search/__tests__/integration.test.ts +649 -0
- package/src/search/__tests__/query-engine.test.ts +334 -0
- package/src/search/__tests__/similarity.test.ts +78 -0
- package/src/search/__tests__/vector-store.test.ts +281 -0
- package/src/search/chunk-extractor.ts +167 -0
- package/src/search/embedder.ts +209 -0
- package/src/search/graph-enricher.ts +95 -0
- package/src/search/indexer.ts +483 -0
- package/src/search/lexical-searcher.ts +190 -0
- package/src/search/query-engine.ts +225 -0
- package/src/search/vector-store.ts +311 -0
- package/src/types/index.ts +572 -0
- package/src/utils/__tests__/ansi.test.ts +54 -0
- package/src/utils/__tests__/frontmatter.test.ts +79 -0
- package/src/utils/__tests__/sanitize.test.ts +229 -0
- package/src/utils/ansi.ts +19 -0
- package/src/utils/context.ts +44 -0
- package/src/utils/frontmatter.ts +27 -0
- package/src/utils/sanitize.ts +78 -0
- package/test/e2e/lifecycle.test.ts +330 -0
- package/test/fixtures/mock-planner-hang.ts +5 -0
- package/test/fixtures/mock-planner.ts +26 -0
- package/test/fixtures/mock-reviewer-bad.ts +8 -0
- package/test/fixtures/mock-reviewer-retry.ts +34 -0
- package/test/fixtures/mock-reviewer.ts +18 -0
- package/test/fixtures/sample-project/src/circular-a.ts +6 -0
- package/test/fixtures/sample-project/src/circular-b.ts +6 -0
- package/test/fixtures/sample-project/src/config.ts +15 -0
- package/test/fixtures/sample-project/src/main.ts +19 -0
- package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
- package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
- package/test/fixtures/sample-project/src/types.ts +14 -0
- package/test/fixtures/sample-project/src/utils/index.ts +14 -0
- package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { Logger } from 'winston';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages all file I/O for plan artifacts: logs, diffs, plan summaries.
|
|
7
|
+
* The Orchestrator delegates ALL file reads/writes to this class.
|
|
8
|
+
* It NEVER touches state JSON (that's StateManager's job).
|
|
9
|
+
*/
|
|
10
|
+
export class PlanFileManager {
|
|
11
|
+
constructor(
|
|
12
|
+
private projectRoot: string,
|
|
13
|
+
private logger: Logger,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
private resolve(...segments: string[]): string {
|
|
17
|
+
return path.join(this.projectRoot, 'tasks-management', ...segments);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
saveRawLog(taskId: string, version: number, content: string): void {
|
|
21
|
+
const filePath = this.resolve('logs', `${taskId}-v${version}-raw.log`);
|
|
22
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
23
|
+
fs.writeFileSync(filePath, content);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
saveStrippedLog(taskId: string, version: number, content: string): void {
|
|
27
|
+
const filePath = this.resolve('logs', `${taskId}-v${version}.log`);
|
|
28
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(filePath, content);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
saveDiff(taskId: string, version: number, diff: string): void {
|
|
33
|
+
const filePath = this.resolve('plans', `${taskId}-v${version}.diff`);
|
|
34
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
35
|
+
fs.writeFileSync(filePath, diff);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
saveReviewRawLog(taskId: string, version: number, content: string): void {
|
|
39
|
+
const filePath = this.resolve('logs', `${taskId}-v${version}-review-raw.log`);
|
|
40
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
|
+
fs.writeFileSync(filePath, content);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
loadDiff(taskId: string, version: number): string {
|
|
45
|
+
return fs.readFileSync(this.resolve('plans', `${taskId}-v${version}.diff`), 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
loadPlanSummary(taskId: string, version: number): string | null {
|
|
49
|
+
const filePath = this.resolve('plans', `${taskId}-v${version}.md`);
|
|
50
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Returns the list of relative paths to commit per Execution Rule #14 */
|
|
54
|
+
getCommitFileList(taskId: string, version: number, includeLogs: boolean): string[] {
|
|
55
|
+
const files = [`tasks-management/plans/${taskId}-v${version}.diff`];
|
|
56
|
+
if (includeLogs) {
|
|
57
|
+
files.push(`tasks-management/logs/${taskId}-v${version}.log`);
|
|
58
|
+
}
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
deleteSessionRule(taskId: string): void {
|
|
63
|
+
const filePath = this.resolve('rules', 'session', `${taskId}.md`);
|
|
64
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execFile } from 'child_process';
|
|
4
|
+
import type { Logger } from 'winston';
|
|
5
|
+
import { NomosError } from './errors.js';
|
|
6
|
+
import type { NomosConfig } from '../types/index.js';
|
|
7
|
+
|
|
8
|
+
const WIN_EXTENSIONS = ['.exe', '.cmd', '.bat'];
|
|
9
|
+
|
|
10
|
+
async function isExecutable(filePath: string): Promise<boolean> {
|
|
11
|
+
return new Promise(resolve => {
|
|
12
|
+
fs.access(filePath, fs.constants.X_OK, err => resolve(!err));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function resolveBinary(cmd: string): Promise<string> {
|
|
17
|
+
// Absolute path — check directly
|
|
18
|
+
if (path.isAbsolute(cmd)) {
|
|
19
|
+
if (await isExecutable(cmd)) return cmd;
|
|
20
|
+
throw new NomosError('binary_not_found', `Binary "${cmd}" not found or not executable`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const dirs = process.env.PATH?.split(path.delimiter) ?? [];
|
|
24
|
+
const candidates = process.platform === 'win32'
|
|
25
|
+
? [cmd, ...WIN_EXTENSIONS.map(ext => cmd + ext)]
|
|
26
|
+
: [cmd];
|
|
27
|
+
|
|
28
|
+
for (const dir of dirs) {
|
|
29
|
+
for (const name of candidates) {
|
|
30
|
+
const full = path.join(dir, name);
|
|
31
|
+
if (await isExecutable(full)) return full;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new NomosError('binary_not_found', `Binary "${cmd}" not found in PATH`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runPreflight(
|
|
39
|
+
config: NomosConfig,
|
|
40
|
+
logger: Logger,
|
|
41
|
+
): Promise<{ planner: string; reviewer: string }> {
|
|
42
|
+
const [planner, reviewer] = await Promise.all([
|
|
43
|
+
resolveBinary(config.binaries.planner.cmd),
|
|
44
|
+
resolveBinary(config.binaries.reviewer.cmd),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Optionally probe each binary with --version (non-fatal)
|
|
48
|
+
for (const [name, resolved] of [['planner', planner], ['reviewer', reviewer]] as const) {
|
|
49
|
+
await new Promise<void>(resolve => {
|
|
50
|
+
const timer = setTimeout(resolve, 5000);
|
|
51
|
+
execFile(resolved, ['--version'], { timeout: 5000 }, (err, stdout) => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (err) {
|
|
54
|
+
logger.warn(`${name} binary "${resolved}" --version failed: ${err.message}`);
|
|
55
|
+
} else {
|
|
56
|
+
logger.debug(`${name} binary version: ${stdout.trim()}`);
|
|
57
|
+
}
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { planner, reviewer };
|
|
64
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { NomosError } from './errors.js';
|
|
5
|
+
import type { PromptOptions, ReviewPromptOptions } from '../types/index.js';
|
|
6
|
+
|
|
7
|
+
export function assemblePrompt(options: PromptOptions): string {
|
|
8
|
+
const sections: string[] = [];
|
|
9
|
+
|
|
10
|
+
sections.push(`[SYSTEM RULES]\n${options.globalRules}`);
|
|
11
|
+
|
|
12
|
+
if (options.domainRules.trim()) {
|
|
13
|
+
sections.push(`[DOMAIN RULES]\n${options.domainRules}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (options.sessionRules !== null) {
|
|
17
|
+
sections.push(`[SESSION CONSTRAINTS]\n${options.sessionRules}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sections.push(`[TASK REQUIREMENTS]\n${options.taskBody}`);
|
|
21
|
+
|
|
22
|
+
// BLK-3 fix: contextFiles section
|
|
23
|
+
if (options.contextFiles.length > 0) {
|
|
24
|
+
const fileList = options.contextFiles.map(f => `- ${f}`).join('\n');
|
|
25
|
+
sections.push(
|
|
26
|
+
`[CONTEXT FILES]\n` +
|
|
27
|
+
`The following files are relevant to this task. Read and understand them before planning:\n` +
|
|
28
|
+
fileList
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (options.architecturalConstraints !== null) {
|
|
33
|
+
sections.push(
|
|
34
|
+
`[ARCHITECTURAL CONSTRAINTS]\n` +
|
|
35
|
+
`The following symbols from your context files are consumed by other modules. ` +
|
|
36
|
+
`Your implementation MUST preserve their signatures and contracts:\n` +
|
|
37
|
+
options.architecturalConstraints
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.previousFeedback !== null && options.previousFeedback.length > 0) {
|
|
42
|
+
const issueLines = options.previousFeedback
|
|
43
|
+
.map(i => `- [${i.severity}] ${i.description}: ${i.suggestion}`)
|
|
44
|
+
.join('\n');
|
|
45
|
+
sections.push(
|
|
46
|
+
`[PREVIOUS REVIEW FEEDBACK]\n` +
|
|
47
|
+
`The following issues were identified in v${options.previousVersion} and MUST be addressed:\n` +
|
|
48
|
+
issueLines
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sections.push(
|
|
53
|
+
`[INSTRUCTION]\n` +
|
|
54
|
+
`Generate a detailed implementation plan for the above task.\n` +
|
|
55
|
+
`Output your plan in Markdown format.`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return sections.join('\n\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assembleReviewPrompt(options: ReviewPromptOptions): string {
|
|
62
|
+
const sections: string[] = [];
|
|
63
|
+
|
|
64
|
+
sections.push(
|
|
65
|
+
`[REVIEW REQUEST]\n` +
|
|
66
|
+
`You are a code review expert. Review the following implementation plan diff.\n` +
|
|
67
|
+
`Evaluate quality, security, architecture, and correctness.`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
sections.push(`[PLAN DIFF]\n${options.planDiff}`);
|
|
71
|
+
|
|
72
|
+
if (options.affectedFileSnippets && options.affectedFileSnippets.length > 0) {
|
|
73
|
+
const snippets = options.affectedFileSnippets
|
|
74
|
+
.map(({ file, snippet }) => `// ${file}\n${snippet}`)
|
|
75
|
+
.join('\n\n---\n\n');
|
|
76
|
+
sections.push(
|
|
77
|
+
`[AFFECTED FILES]\n` +
|
|
78
|
+
`The following files reference code changed in this diff. ` +
|
|
79
|
+
`Use them to assess side-effects and dependency impact:\n\n` +
|
|
80
|
+
snippets
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (options.planSummary) {
|
|
85
|
+
sections.push(`[DEVELOPER NOTES]\n${options.planSummary}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.globalRules.trim()) {
|
|
89
|
+
sections.push(`[SYSTEM RULES]\n${options.globalRules}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.domainRules.trim()) {
|
|
93
|
+
sections.push(`[DOMAIN RULES]\n${options.domainRules}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (options.mode === 'auto') {
|
|
97
|
+
sections.push(
|
|
98
|
+
`[ZERO-TOLERANCE CLAUSE]\n` +
|
|
99
|
+
`You are operating in auto mode. Any high-severity security issue MUST result in ` +
|
|
100
|
+
`a score below 0.5, regardless of other positive qualities.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
sections.push(
|
|
105
|
+
`[INSTRUCTION]\n` +
|
|
106
|
+
`Respond ONLY with a JSON object matching this exact schema. ` +
|
|
107
|
+
`Do not include any other text, explanation, or markdown fences:\n` +
|
|
108
|
+
`{\n` +
|
|
109
|
+
` "score": <number between 0.0 and 1.0>,\n` +
|
|
110
|
+
` "summary": "<string, minimum 10 characters describing the overall quality>",\n` +
|
|
111
|
+
` "issues": [\n` +
|
|
112
|
+
` {\n` +
|
|
113
|
+
` "severity": "<high|medium|low>",\n` +
|
|
114
|
+
` "category": "<security|performance|architecture|correctness|maintainability>",\n` +
|
|
115
|
+
` "description": "<string, minimum 5 characters>",\n` +
|
|
116
|
+
` "suggestion": "<string, minimum 5 characters>"\n` +
|
|
117
|
+
` }\n` +
|
|
118
|
+
` ]\n` +
|
|
119
|
+
`}\n\n` +
|
|
120
|
+
`Scoring guide:\n` +
|
|
121
|
+
`- 0.9–1.0: Excellent, approved for merge\n` +
|
|
122
|
+
`- 0.7–0.9: Good, minor issues\n` +
|
|
123
|
+
`- 0.5–0.7: Needs improvement\n` +
|
|
124
|
+
`- 0.0–0.5: Significant problems requiring rework`
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return sections.join('\n\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadRules(
|
|
131
|
+
projectRoot: string,
|
|
132
|
+
taskId: string,
|
|
133
|
+
): Promise<{ global: string; domain: string; session: string | null; rulesHash: string; rulesList: string[] }> {
|
|
134
|
+
const rulesDir = path.join(projectRoot, 'tasks-management', 'rules');
|
|
135
|
+
|
|
136
|
+
// global.md is required
|
|
137
|
+
const globalPath = path.join(rulesDir, 'global.md');
|
|
138
|
+
let globalContent: string;
|
|
139
|
+
try {
|
|
140
|
+
globalContent = await fs.readFile(globalPath, 'utf8');
|
|
141
|
+
} catch {
|
|
142
|
+
throw new NomosError(
|
|
143
|
+
'rules_missing',
|
|
144
|
+
`Required rules file not found: ${globalPath}. ` +
|
|
145
|
+
`Run: arc init to scaffold the project structure.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// backend.md is optional
|
|
150
|
+
let domainContent = '';
|
|
151
|
+
try {
|
|
152
|
+
domainContent = await fs.readFile(path.join(rulesDir, 'backend.md'), 'utf8');
|
|
153
|
+
} catch { /* not found — that's ok */ }
|
|
154
|
+
|
|
155
|
+
// session/{taskId}.md is optional
|
|
156
|
+
let sessionContent: string | null = null;
|
|
157
|
+
try {
|
|
158
|
+
sessionContent = await fs.readFile(
|
|
159
|
+
path.join(rulesDir, 'session', `${taskId}.md`), 'utf8');
|
|
160
|
+
} catch { /* not found — that's ok */ }
|
|
161
|
+
|
|
162
|
+
const rulesHash = `sha256:${createHash('sha256')
|
|
163
|
+
.update(globalContent + domainContent + (sessionContent ?? ''))
|
|
164
|
+
.digest('hex')}`;
|
|
165
|
+
|
|
166
|
+
const rulesList = [
|
|
167
|
+
'global.md',
|
|
168
|
+
...(domainContent ? ['backend.md'] : []),
|
|
169
|
+
...(sessionContent !== null ? [`session/${taskId}.md`] : []),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
return { global: globalContent, domain: domainContent, session: sessionContent, rulesHash, rulesList };
|
|
173
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ReviewResult, ExecutionMode } from '../types/index.js';
|
|
3
|
+
|
|
4
|
+
// Stage 1 — JSON extraction
|
|
5
|
+
|
|
6
|
+
function extractFirstJsonBlock(raw: string): string | null {
|
|
7
|
+
let depth = 0;
|
|
8
|
+
let start = -1;
|
|
9
|
+
let inString = false;
|
|
10
|
+
let escape = false;
|
|
11
|
+
for (let i = 0; i < raw.length; i++) {
|
|
12
|
+
const ch = raw[i];
|
|
13
|
+
if (escape) { escape = false; continue; }
|
|
14
|
+
if (ch === '\\') { escape = true; continue; }
|
|
15
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
16
|
+
if (inString) continue;
|
|
17
|
+
if (ch === '{') { if (depth === 0) start = i; depth++; }
|
|
18
|
+
if (ch === '}') {
|
|
19
|
+
depth--;
|
|
20
|
+
if (depth === 0 && start !== -1) return raw.slice(start, i + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractJson(raw: string): object | null {
|
|
27
|
+
// Try 1: direct parse
|
|
28
|
+
try { return JSON.parse(raw); } catch {}
|
|
29
|
+
// Try 2: markdown fence extraction
|
|
30
|
+
const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
31
|
+
if (fenceMatch) {
|
|
32
|
+
try { return JSON.parse(fenceMatch[1].trim()); } catch {}
|
|
33
|
+
}
|
|
34
|
+
// Try 3: brace-depth extraction
|
|
35
|
+
const block = extractFirstJsonBlock(raw);
|
|
36
|
+
if (block) {
|
|
37
|
+
try { return JSON.parse(block); } catch {}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Stage 2 — Schema validation
|
|
43
|
+
|
|
44
|
+
const ReviewResultSchema = z.object({
|
|
45
|
+
score: z.number().min(0).max(2), // clamp handled in stage 3
|
|
46
|
+
summary: z.string().min(10),
|
|
47
|
+
issues: z.array(z.object({
|
|
48
|
+
severity: z.enum(['high', 'medium', 'low']),
|
|
49
|
+
category: z.enum(['security', 'performance', 'architecture', 'correctness', 'maintainability']),
|
|
50
|
+
description: z.string().min(5),
|
|
51
|
+
suggestion: z.string().min(5),
|
|
52
|
+
})),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// RT2-4.2 fix: Accept the actual execution mode as a parameter instead of
|
|
56
|
+
// hardcoding 'auto'. The previous version baked mode: 'auto' into every
|
|
57
|
+
// persisted HistoryEntry, corrupting history from the first supervised run.
|
|
58
|
+
export function validateReviewSchema(obj: object, mode: ExecutionMode): ReviewResult | null {
|
|
59
|
+
const result = ReviewResultSchema.safeParse(obj);
|
|
60
|
+
if (!result.success) return null;
|
|
61
|
+
return { ...result.data, mode } as ReviewResult;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stage 3 — Semantic validation
|
|
65
|
+
|
|
66
|
+
export function semanticValidation(review: ReviewResult): { valid: boolean; reason?: string } {
|
|
67
|
+
// Clamp score
|
|
68
|
+
if (review.score > 1.0) {
|
|
69
|
+
review.score = 1.0;
|
|
70
|
+
}
|
|
71
|
+
if (review.score < 0.0) {
|
|
72
|
+
review.score = 0.0;
|
|
73
|
+
}
|
|
74
|
+
if (review.score < 0.5 && review.issues.length === 0) {
|
|
75
|
+
return { valid: false, reason: 'Low score must have supporting issues.' };
|
|
76
|
+
}
|
|
77
|
+
if (review.score >= 0.9 && review.issues.some(i => i.severity === 'high')) {
|
|
78
|
+
return { valid: false, reason: 'High severity issues cannot pass with score >= 0.9.' };
|
|
79
|
+
}
|
|
80
|
+
return { valid: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// RT2-4.2 fix: mode parameter threaded through the entire pipeline
|
|
84
|
+
export function parseReviewOutput(
|
|
85
|
+
raw: string,
|
|
86
|
+
mode: ExecutionMode,
|
|
87
|
+
): { result: ReviewResult | null; error?: string } {
|
|
88
|
+
const obj = extractJson(raw);
|
|
89
|
+
if (!obj) return { result: null, error: 'Stage 1 failed: no JSON found in reviewer output.' };
|
|
90
|
+
const validated = validateReviewSchema(obj, mode);
|
|
91
|
+
if (!validated) return { result: null, error: 'Stage 2 failed: JSON does not match ReviewResult schema.' };
|
|
92
|
+
const semantic = semanticValidation(validated);
|
|
93
|
+
if (!semantic.valid) return { result: null, error: `Stage 3 failed: ${semantic.reason}` };
|
|
94
|
+
return { result: validated };
|
|
95
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as lockfile from 'proper-lockfile';
|
|
5
|
+
import type { Logger } from 'winston';
|
|
6
|
+
import { NomosError } from './errors.js';
|
|
7
|
+
import type { TaskState, TaskStatus, StateTransitionOptions } from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
// ── Zod Schema ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const ReviewIssueSchema = z.object({
|
|
12
|
+
severity: z.enum(['high', 'medium', 'low']),
|
|
13
|
+
category: z.enum(['security', 'performance', 'architecture', 'correctness', 'maintainability']),
|
|
14
|
+
description: z.string().min(5),
|
|
15
|
+
suggestion: z.string().min(5),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const ReviewResultSchema = z.object({
|
|
19
|
+
score: z.number().min(0).max(1),
|
|
20
|
+
mode: z.enum(['supervised', 'auto', 'dry-run']),
|
|
21
|
+
issues: z.array(ReviewIssueSchema),
|
|
22
|
+
summary: z.string().min(10),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const HistoryEntrySchema = z.object({
|
|
26
|
+
version: z.number().int().nonnegative(),
|
|
27
|
+
step: z.enum(['planning', 'reviewing']),
|
|
28
|
+
mode: z.enum(['supervised', 'auto', 'dry-run']),
|
|
29
|
+
binary: z.string(),
|
|
30
|
+
started_at: z.string().datetime(),
|
|
31
|
+
completed_at: z.string().datetime(),
|
|
32
|
+
raw_output: z.string(),
|
|
33
|
+
output_hash: z.string(),
|
|
34
|
+
input_tokens: z.number().nonnegative(), // RT2-4.2 fix: separate input tokens
|
|
35
|
+
output_tokens: z.number().nonnegative(), // RT2-4.2 fix: separate output tokens
|
|
36
|
+
tokens_used: z.number().nonnegative(), // total (input + output)
|
|
37
|
+
tokens_source: z.enum(['metered', 'estimated']),
|
|
38
|
+
rules_snapshot: z.array(z.string()),
|
|
39
|
+
review: ReviewResultSchema.nullable(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const TaskMetaSchema = z.object({
|
|
43
|
+
status: z.enum([
|
|
44
|
+
'init', 'planning', 'pending_review', 'reviewing',
|
|
45
|
+
'refinement', 'approved', 'merged', 'discarded',
|
|
46
|
+
'failed', 'merge_conflict', 'stalled',
|
|
47
|
+
]),
|
|
48
|
+
created_at: z.string().datetime(),
|
|
49
|
+
updated_at: z.string().datetime(),
|
|
50
|
+
approval_reason: z.enum(['score_threshold', 'max_iterations_reached']).optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const TaskStateSchema = z.object({
|
|
54
|
+
schema_version: z.number().int().nonnegative(),
|
|
55
|
+
task_id: z.string(),
|
|
56
|
+
current_version: z.number().int().nonnegative(),
|
|
57
|
+
meta: TaskMetaSchema,
|
|
58
|
+
orchestration: z.object({
|
|
59
|
+
planner_bin: z.string(),
|
|
60
|
+
reviewer_bin: z.string(),
|
|
61
|
+
}),
|
|
62
|
+
shadow_branch: z.object({
|
|
63
|
+
branch: z.string(),
|
|
64
|
+
worktree: z.string(),
|
|
65
|
+
base_commit: z.string(),
|
|
66
|
+
status: z.enum(['active', 'merged', 'discarded']),
|
|
67
|
+
}),
|
|
68
|
+
context: z.object({
|
|
69
|
+
files: z.array(z.string()),
|
|
70
|
+
rules: z.array(z.string()),
|
|
71
|
+
rules_hash: z.string(),
|
|
72
|
+
}),
|
|
73
|
+
budget: z.object({
|
|
74
|
+
tokens_used: z.number().nonnegative(),
|
|
75
|
+
estimated_cost_usd: z.number().nonnegative(),
|
|
76
|
+
}),
|
|
77
|
+
history: z.array(HistoryEntrySchema),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Schema migration ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const CURRENT_SCHEMA_VERSION = 1;
|
|
83
|
+
|
|
84
|
+
// Migrations map: key is the FROM version. Add entries for future schema bumps.
|
|
85
|
+
// Phase 1a: v0 → v1 is a no-op identity migration. Files written before schema_version
|
|
86
|
+
// was introduced have the same data format as v1; we just stamp the version field.
|
|
87
|
+
// Infrastructure must exist from day one (W5 fix).
|
|
88
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
89
|
+
const migrations: Record<number, (state: any) => any> = {
|
|
90
|
+
0: (state: any) => state, // v0 → v1: no data transformation needed (Phase 1a)
|
|
91
|
+
// Future: 1: migrateV1toV2,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function migrateState(raw: any): any {
|
|
95
|
+
let version: number = raw.schema_version ?? 0;
|
|
96
|
+
while (version < CURRENT_SCHEMA_VERSION) {
|
|
97
|
+
const migrator = migrations[version];
|
|
98
|
+
if (!migrator) {
|
|
99
|
+
throw new NomosError(
|
|
100
|
+
'state_migration_failed',
|
|
101
|
+
`No migration path from schema_version ${version} to ${CURRENT_SCHEMA_VERSION}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
raw = migrator(raw);
|
|
105
|
+
version++;
|
|
106
|
+
}
|
|
107
|
+
raw.schema_version = CURRENT_SCHEMA_VERSION;
|
|
108
|
+
return raw;
|
|
109
|
+
}
|
|
110
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
111
|
+
|
|
112
|
+
// ── Valid FSM transitions ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
|
115
|
+
init: ['planning', 'discarded'],
|
|
116
|
+
planning: ['pending_review', 'failed', 'stalled', 'discarded'],
|
|
117
|
+
pending_review: ['reviewing', 'discarded'],
|
|
118
|
+
reviewing: ['refinement', 'approved', 'failed', 'discarded'],
|
|
119
|
+
refinement: ['planning', 'discarded'],
|
|
120
|
+
approved: ['merged', 'merge_conflict', 'discarded'],
|
|
121
|
+
merge_conflict: ['approved', 'discarded'],
|
|
122
|
+
stalled: ['planning', 'discarded'],
|
|
123
|
+
failed: ['planning', 'discarded'],
|
|
124
|
+
merged: [],
|
|
125
|
+
discarded: [],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── StateManager ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export class StateManager {
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly stateDir: string,
|
|
133
|
+
private readonly logger: Logger,
|
|
134
|
+
) {}
|
|
135
|
+
|
|
136
|
+
private statePath(taskId: string): string {
|
|
137
|
+
return path.join(this.stateDir, `${taskId}.json`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── read ────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async read(taskId: string): Promise<TaskState> {
|
|
143
|
+
const filePath = this.statePath(taskId);
|
|
144
|
+
if (!fs.existsSync(filePath)) {
|
|
145
|
+
throw new NomosError('task_not_found', `No state file found for task "${taskId}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let raw: unknown;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new NomosError('state_migration_failed', `Failed to parse state file: ${String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const migrated = migrateState(raw);
|
|
156
|
+
const result = TaskStateSchema.safeParse(migrated);
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
159
|
+
throw new NomosError('state_migration_failed', `State validation failed for "${taskId}": ${issues}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result.data as TaskState;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── write ───────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async write(taskId: string, state: TaskState): Promise<void> {
|
|
168
|
+
const filePath = this.statePath(taskId);
|
|
169
|
+
const tmpPath = `${filePath}.tmp`;
|
|
170
|
+
|
|
171
|
+
// Ensure state dir exists
|
|
172
|
+
fs.mkdirSync(this.stateDir, { recursive: true });
|
|
173
|
+
|
|
174
|
+
// Acquire lock — realpath: false so locking works even before file exists
|
|
175
|
+
const release = await lockfile.lock(filePath, {
|
|
176
|
+
retries: { retries: 5, minTimeout: 200, maxTimeout: 5000 },
|
|
177
|
+
stale: 30000, // C2 fix: auto-break locks older than 30s from crashed processes
|
|
178
|
+
realpath: false,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const json = JSON.stringify(state, null, 2);
|
|
183
|
+
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
184
|
+
|
|
185
|
+
// fsync: flush OS write buffer to disk before rename for crash safety
|
|
186
|
+
const fd = fs.openSync(tmpPath, 'r+');
|
|
187
|
+
try {
|
|
188
|
+
fs.fsyncSync(fd);
|
|
189
|
+
} finally {
|
|
190
|
+
fs.closeSync(fd);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Atomic rename: tmp → final
|
|
194
|
+
fs.renameSync(tmpPath, filePath);
|
|
195
|
+
} finally {
|
|
196
|
+
await release();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── create ──────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async create(taskId: string, initialState: TaskState): Promise<void> {
|
|
203
|
+
const filePath = this.statePath(taskId);
|
|
204
|
+
if (fs.existsSync(filePath)) {
|
|
205
|
+
throw new NomosError('task_exists', `Task "${taskId}" already exists`);
|
|
206
|
+
}
|
|
207
|
+
await this.write(taskId, initialState);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── transition ──────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async transition(
|
|
213
|
+
taskId: string,
|
|
214
|
+
newStatus: TaskStatus,
|
|
215
|
+
options?: StateTransitionOptions,
|
|
216
|
+
): Promise<TaskState> {
|
|
217
|
+
const state = await this.read(taskId);
|
|
218
|
+
const currentStatus = state.meta.status;
|
|
219
|
+
const allowed = VALID_TRANSITIONS[currentStatus];
|
|
220
|
+
|
|
221
|
+
if (!allowed.includes(newStatus)) {
|
|
222
|
+
throw new NomosError(
|
|
223
|
+
'invalid_transition',
|
|
224
|
+
`Cannot transition task "${taskId}" from "${currentStatus}" to "${newStatus}". ` +
|
|
225
|
+
`Allowed: [${allowed.join(', ')}]`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
state.meta.status = newStatus;
|
|
230
|
+
state.meta.updated_at = new Date().toISOString();
|
|
231
|
+
|
|
232
|
+
if (options?.reason) {
|
|
233
|
+
this.logger.warn(`[nomos:transition] task=${taskId} reason=${options.reason}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options?.version_increment) {
|
|
237
|
+
state.current_version++;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (options?.history_entry) {
|
|
241
|
+
state.history.push(options.history_entry);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (options?.review_result && state.history.length > 0) {
|
|
245
|
+
state.history[state.history.length - 1]!.review = options.review_result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (options?.approval_reason) {
|
|
249
|
+
state.meta.approval_reason = options.approval_reason;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await this.write(taskId, state);
|
|
253
|
+
return state;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── cleanupTempFiles ────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
cleanupTempFiles(stateDir: string): void {
|
|
259
|
+
if (!fs.existsSync(stateDir)) return;
|
|
260
|
+
|
|
261
|
+
const entries = fs.readdirSync(stateDir);
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
if (entry.endsWith('.json.tmp')) {
|
|
264
|
+
const tmpPath = path.join(stateDir, entry);
|
|
265
|
+
this.logger.warn(`[nomos:cleanup] Removing orphaned temp file: ${tmpPath}`);
|
|
266
|
+
fs.rmSync(tmpPath, { force: true });
|
|
267
|
+
}
|
|
268
|
+
// NOTE: Do NOT remove .lock files — proper-lockfile's stale: 30000 handles that.
|
|
269
|
+
// Manual removal races with lock ownership tracking (L-3 fix).
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── listTasks ───────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
async listTasks(): Promise<TaskState[]> {
|
|
276
|
+
if (!fs.existsSync(this.stateDir)) return [];
|
|
277
|
+
|
|
278
|
+
const entries = fs.readdirSync(this.stateDir);
|
|
279
|
+
const tasks: TaskState[] = [];
|
|
280
|
+
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
if (!entry.endsWith('.json') || entry.endsWith('.json.tmp')) continue;
|
|
283
|
+
const taskId = entry.replace(/\.json$/, '');
|
|
284
|
+
try {
|
|
285
|
+
const task = await this.read(taskId);
|
|
286
|
+
tasks.push(task);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
this.logger.warn(`[nomos:listTasks] Skipping unreadable state file "${entry}": ${String(err)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return tasks;
|
|
293
|
+
}
|
|
294
|
+
}
|