@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.
Files changed (160) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.nomos-config.json +5 -0
  3. package/CLAUDE.md +108 -0
  4. package/LICENSE +190 -0
  5. package/README.md +569 -0
  6. package/dist/cli.js +21120 -0
  7. package/docs/auth/googel_plan.yaml +1093 -0
  8. package/docs/auth/google_task.md +235 -0
  9. package/docs/auth/hardened_blueprint.yaml +1658 -0
  10. package/docs/auth/red_team_report.yaml +336 -0
  11. package/docs/auth/session_state.yaml +162 -0
  12. package/docs/certificate/cer_enhance_plan.md +605 -0
  13. package/docs/certificate/certificate_report.md +338 -0
  14. package/docs/dev_overview.md +419 -0
  15. package/docs/feature_assessment.md +156 -0
  16. package/docs/how_it_works.md +78 -0
  17. package/docs/infrastructure/map.md +867 -0
  18. package/docs/init/master_plan.md +3581 -0
  19. package/docs/init/red_team_report.md +215 -0
  20. package/docs/init/report_phase_1a.md +304 -0
  21. package/docs/integrity-gate/enhance_drift.md +703 -0
  22. package/docs/integrity-gate/overview.md +108 -0
  23. package/docs/management/manger-task.md +99 -0
  24. package/docs/management/scafffold.md +76 -0
  25. package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
  26. package/docs/map/RED_TEAM_REPORT.md +159 -0
  27. package/docs/map/map_task.md +147 -0
  28. package/docs/map/semantic_graph_task.md +792 -0
  29. package/docs/map/semantic_master_plan.md +705 -0
  30. package/docs/phase7/TEAM_RED.md +249 -0
  31. package/docs/phase7/plan.md +1682 -0
  32. package/docs/phase7/task.md +275 -0
  33. package/docs/prompts/USAGE.md +312 -0
  34. package/docs/prompts/architect.md +165 -0
  35. package/docs/prompts/executer.md +190 -0
  36. package/docs/prompts/hardener.md +190 -0
  37. package/docs/prompts/red_team.md +146 -0
  38. package/docs/verification/goveranance-overview.md +396 -0
  39. package/docs/verification/governance-overview.md +245 -0
  40. package/docs/verification/verification-arc-ar.md +560 -0
  41. package/docs/verification/verification-architecture.md +560 -0
  42. package/docs/very_next.md +52 -0
  43. package/docs/whitepaper.md +89 -0
  44. package/overview.md +1469 -0
  45. package/package.json +63 -0
  46. package/src/adapters/__tests__/git.test.ts +296 -0
  47. package/src/adapters/__tests__/stdio.test.ts +70 -0
  48. package/src/adapters/git.ts +226 -0
  49. package/src/adapters/pty.ts +159 -0
  50. package/src/adapters/stdio.ts +113 -0
  51. package/src/cli.ts +83 -0
  52. package/src/commands/apply.ts +47 -0
  53. package/src/commands/auth.ts +301 -0
  54. package/src/commands/certificate.ts +89 -0
  55. package/src/commands/discard.ts +24 -0
  56. package/src/commands/drift.ts +116 -0
  57. package/src/commands/index.ts +78 -0
  58. package/src/commands/init.ts +121 -0
  59. package/src/commands/list.ts +75 -0
  60. package/src/commands/map.ts +55 -0
  61. package/src/commands/plan.ts +30 -0
  62. package/src/commands/review.ts +58 -0
  63. package/src/commands/run.ts +63 -0
  64. package/src/commands/search.ts +147 -0
  65. package/src/commands/show.ts +63 -0
  66. package/src/commands/status.ts +59 -0
  67. package/src/core/__tests__/budget.test.ts +213 -0
  68. package/src/core/__tests__/certificate.test.ts +385 -0
  69. package/src/core/__tests__/config.test.ts +191 -0
  70. package/src/core/__tests__/preflight.test.ts +24 -0
  71. package/src/core/__tests__/prompt.test.ts +358 -0
  72. package/src/core/__tests__/review.test.ts +161 -0
  73. package/src/core/__tests__/state.test.ts +362 -0
  74. package/src/core/auth/__tests__/manager.test.ts +166 -0
  75. package/src/core/auth/__tests__/server.test.ts +220 -0
  76. package/src/core/auth/gcp-projects.ts +160 -0
  77. package/src/core/auth/manager.ts +114 -0
  78. package/src/core/auth/server.ts +141 -0
  79. package/src/core/budget.ts +119 -0
  80. package/src/core/certificate.ts +502 -0
  81. package/src/core/config.ts +212 -0
  82. package/src/core/errors.ts +54 -0
  83. package/src/core/factory.ts +49 -0
  84. package/src/core/graph/__tests__/builder.test.ts +272 -0
  85. package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
  86. package/src/core/graph/__tests__/enricher.test.ts +299 -0
  87. package/src/core/graph/__tests__/parser.test.ts +200 -0
  88. package/src/core/graph/__tests__/pipeline.test.ts +202 -0
  89. package/src/core/graph/__tests__/renderer.test.ts +128 -0
  90. package/src/core/graph/__tests__/resolver.test.ts +185 -0
  91. package/src/core/graph/__tests__/scanner.test.ts +231 -0
  92. package/src/core/graph/__tests__/show.test.ts +134 -0
  93. package/src/core/graph/builder.ts +303 -0
  94. package/src/core/graph/constraints.ts +94 -0
  95. package/src/core/graph/contract-writer.ts +93 -0
  96. package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
  97. package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
  98. package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
  99. package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
  100. package/src/core/graph/drift/classifier.ts +165 -0
  101. package/src/core/graph/drift/comparator.ts +205 -0
  102. package/src/core/graph/drift/reporter.ts +77 -0
  103. package/src/core/graph/enricher.ts +251 -0
  104. package/src/core/graph/grammar-paths.ts +30 -0
  105. package/src/core/graph/html-template.ts +493 -0
  106. package/src/core/graph/map-schema.ts +137 -0
  107. package/src/core/graph/parser.ts +336 -0
  108. package/src/core/graph/pipeline.ts +209 -0
  109. package/src/core/graph/renderer.ts +92 -0
  110. package/src/core/graph/resolver.ts +195 -0
  111. package/src/core/graph/scanner.ts +145 -0
  112. package/src/core/logger.ts +46 -0
  113. package/src/core/orchestrator.ts +792 -0
  114. package/src/core/plan-file-manager.ts +66 -0
  115. package/src/core/preflight.ts +64 -0
  116. package/src/core/prompt.ts +173 -0
  117. package/src/core/review.ts +95 -0
  118. package/src/core/state.ts +294 -0
  119. package/src/core/worktree-coordinator.ts +77 -0
  120. package/src/search/__tests__/chunk-extractor.test.ts +339 -0
  121. package/src/search/__tests__/embedder-auth.test.ts +124 -0
  122. package/src/search/__tests__/embedder.test.ts +267 -0
  123. package/src/search/__tests__/graph-enricher.test.ts +178 -0
  124. package/src/search/__tests__/indexer.test.ts +518 -0
  125. package/src/search/__tests__/integration.test.ts +649 -0
  126. package/src/search/__tests__/query-engine.test.ts +334 -0
  127. package/src/search/__tests__/similarity.test.ts +78 -0
  128. package/src/search/__tests__/vector-store.test.ts +281 -0
  129. package/src/search/chunk-extractor.ts +167 -0
  130. package/src/search/embedder.ts +209 -0
  131. package/src/search/graph-enricher.ts +95 -0
  132. package/src/search/indexer.ts +483 -0
  133. package/src/search/lexical-searcher.ts +190 -0
  134. package/src/search/query-engine.ts +225 -0
  135. package/src/search/vector-store.ts +311 -0
  136. package/src/types/index.ts +572 -0
  137. package/src/utils/__tests__/ansi.test.ts +54 -0
  138. package/src/utils/__tests__/frontmatter.test.ts +79 -0
  139. package/src/utils/__tests__/sanitize.test.ts +229 -0
  140. package/src/utils/ansi.ts +19 -0
  141. package/src/utils/context.ts +44 -0
  142. package/src/utils/frontmatter.ts +27 -0
  143. package/src/utils/sanitize.ts +78 -0
  144. package/test/e2e/lifecycle.test.ts +330 -0
  145. package/test/fixtures/mock-planner-hang.ts +5 -0
  146. package/test/fixtures/mock-planner.ts +26 -0
  147. package/test/fixtures/mock-reviewer-bad.ts +8 -0
  148. package/test/fixtures/mock-reviewer-retry.ts +34 -0
  149. package/test/fixtures/mock-reviewer.ts +18 -0
  150. package/test/fixtures/sample-project/src/circular-a.ts +6 -0
  151. package/test/fixtures/sample-project/src/circular-b.ts +6 -0
  152. package/test/fixtures/sample-project/src/config.ts +15 -0
  153. package/test/fixtures/sample-project/src/main.ts +19 -0
  154. package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
  155. package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
  156. package/test/fixtures/sample-project/src/types.ts +14 -0
  157. package/test/fixtures/sample-project/src/utils/index.ts +14 -0
  158. package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
  159. package/tsconfig.json +20 -0
  160. 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
+ }