@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,161 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractJson, validateReviewSchema, semanticValidation, parseReviewOutput } from '../review.js';
3
+ import type { ReviewResult } from '../../types/index.js';
4
+
5
+ // ── Helpers ───────────────────────────────────────────────────────────────────
6
+
7
+ const VALID_REVIEW_OBJ = {
8
+ score: 0.85,
9
+ summary: 'Good implementation with minor issues',
10
+ issues: [
11
+ {
12
+ severity: 'medium' as const,
13
+ category: 'performance' as const,
14
+ description: 'N+1 query pattern',
15
+ suggestion: 'Use batch loading',
16
+ },
17
+ ],
18
+ };
19
+
20
+ const VALID_REVIEW_JSON = JSON.stringify(VALID_REVIEW_OBJ);
21
+
22
+ // ── extractJson ───────────────────────────────────────────────────────────────
23
+
24
+ describe('extractJson', () => {
25
+ it('parses clean JSON directly', () => {
26
+ const result = extractJson(VALID_REVIEW_JSON);
27
+ expect(result).toMatchObject(VALID_REVIEW_OBJ);
28
+ });
29
+
30
+ it('extracts JSON wrapped in markdown fences', () => {
31
+ const raw = `Here is my review:\n\`\`\`json\n${VALID_REVIEW_JSON}\n\`\`\``;
32
+ const result = extractJson(raw);
33
+ expect(result).toMatchObject(VALID_REVIEW_OBJ);
34
+ });
35
+
36
+ it('extracts JSON with preamble text using brace-depth extraction', () => {
37
+ const raw = `Here is my review:\n${VALID_REVIEW_JSON}`;
38
+ const result = extractJson(raw);
39
+ expect(result).toMatchObject(VALID_REVIEW_OBJ);
40
+ });
41
+
42
+ it('returns null for completely invalid output', () => {
43
+ const result = extractJson('This is not JSON at all.');
44
+ expect(result).toBeNull();
45
+ });
46
+ });
47
+
48
+ // ── validateReviewSchema ──────────────────────────────────────────────────────
49
+
50
+ describe('validateReviewSchema', () => {
51
+ it('returns a valid ReviewResult when object matches schema', () => {
52
+ const result = validateReviewSchema(VALID_REVIEW_OBJ, 'supervised');
53
+ expect(result).not.toBeNull();
54
+ expect(result!.score).toBe(0.85);
55
+ });
56
+
57
+ // RT2-4.2 fix: mode must be threaded through, never hardcoded
58
+ it('sets mode to the provided value (dry-run), not a hardcoded value', () => {
59
+ const result = validateReviewSchema(VALID_REVIEW_OBJ, 'dry-run');
60
+ expect(result!.mode).toBe('dry-run');
61
+ });
62
+
63
+ it('returns null when summary is too short', () => {
64
+ const result = validateReviewSchema({ ...VALID_REVIEW_OBJ, summary: 'Short' }, 'supervised');
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ it('returns null when score exceeds 2.0 (schema max)', () => {
69
+ const result = validateReviewSchema({ ...VALID_REVIEW_OBJ, score: 3.0 }, 'supervised');
70
+ expect(result).toBeNull();
71
+ });
72
+ });
73
+
74
+ // ── semanticValidation ────────────────────────────────────────────────────────
75
+
76
+ describe('semanticValidation', () => {
77
+ it('clamps score > 1.0 to 1.0', () => {
78
+ const review: ReviewResult = { ...VALID_REVIEW_OBJ, score: 1.5, mode: 'supervised' };
79
+ const { valid } = semanticValidation(review);
80
+ expect(valid).toBe(true);
81
+ expect(review.score).toBe(1.0);
82
+ });
83
+
84
+ it('rejects low score (< 0.5) with no supporting issues', () => {
85
+ const review: ReviewResult = {
86
+ score: 0.3,
87
+ summary: 'Terrible implementation overall',
88
+ issues: [],
89
+ mode: 'supervised',
90
+ };
91
+ const { valid, reason } = semanticValidation(review);
92
+ expect(valid).toBe(false);
93
+ expect(reason).toContain('Low score');
94
+ });
95
+
96
+ it('rejects score >= 0.9 when a high-severity issue exists', () => {
97
+ const review: ReviewResult = {
98
+ score: 0.95,
99
+ summary: 'Mostly excellent but has a critical flaw',
100
+ issues: [
101
+ {
102
+ severity: 'high' as const,
103
+ category: 'security' as const,
104
+ description: 'SQL injection risk',
105
+ suggestion: 'Use parameterized queries',
106
+ },
107
+ ],
108
+ mode: 'supervised',
109
+ };
110
+ const { valid, reason } = semanticValidation(review);
111
+ expect(valid).toBe(false);
112
+ expect(reason).toContain('High severity');
113
+ });
114
+
115
+ it('accepts a valid review without clamping', () => {
116
+ const review: ReviewResult = { ...VALID_REVIEW_OBJ, mode: 'supervised' };
117
+ const { valid } = semanticValidation(review);
118
+ expect(valid).toBe(true);
119
+ expect(review.score).toBe(0.85);
120
+ });
121
+ });
122
+
123
+ // ── parseReviewOutput ─────────────────────────────────────────────────────────
124
+
125
+ describe('parseReviewOutput', () => {
126
+ it('returns a parsed ReviewResult for valid JSON', () => {
127
+ const { result, error } = parseReviewOutput(VALID_REVIEW_JSON, 'supervised');
128
+ expect(result).not.toBeNull();
129
+ expect(error).toBeUndefined();
130
+ });
131
+
132
+ // RT2-4.2 fix: mode is threaded through — not hardcoded to 'auto'
133
+ it('sets result.mode to the provided mode (supervised)', () => {
134
+ const { result } = parseReviewOutput(VALID_REVIEW_JSON, 'supervised');
135
+ expect(result!.mode).toBe('supervised');
136
+ });
137
+
138
+ it('returns null with stage-1 error for completely unparseable output', () => {
139
+ const { result, error } = parseReviewOutput('not json at all', 'supervised');
140
+ expect(result).toBeNull();
141
+ expect(error).toContain('Stage 1');
142
+ });
143
+
144
+ it('returns null with stage-2 error when JSON fails schema validation', () => {
145
+ const bad = JSON.stringify({ score: 0.8, summary: 'Too short', issues: [] });
146
+ const { result, error } = parseReviewOutput(bad, 'supervised');
147
+ expect(result).toBeNull();
148
+ expect(error).toContain('Stage 2');
149
+ });
150
+
151
+ it('returns null with stage-3 error when semantic validation fails', () => {
152
+ const semanticFail = JSON.stringify({
153
+ score: 0.3,
154
+ summary: 'Very poor implementation here',
155
+ issues: [],
156
+ });
157
+ const { result, error } = parseReviewOutput(semanticFail, 'supervised');
158
+ expect(result).toBeNull();
159
+ expect(error).toContain('Stage 3');
160
+ });
161
+ });
@@ -0,0 +1,362 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { createLogger } from '../logger.js';
6
+ import { StateManager } from '../state.js';
7
+ import { NomosError } from '../errors.js';
8
+ import type { TaskState, HistoryEntry, ReviewResult } from '../../types/index.js';
9
+
10
+ // ── Helpers ──────────────────────────────────────────────────────────────────
11
+
12
+ const logger = createLogger('silent'); // suppressed console output in tests
13
+
14
+ function makeTmpDir(): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-state-test-'));
16
+ }
17
+
18
+ function makeInitialState(taskId: string): TaskState {
19
+ const now = new Date().toISOString();
20
+ return {
21
+ schema_version: 1,
22
+ task_id: taskId,
23
+ current_version: 1,
24
+ meta: {
25
+ status: 'init',
26
+ created_at: now,
27
+ updated_at: now,
28
+ },
29
+ orchestration: {
30
+ planner_bin: 'claude',
31
+ reviewer_bin: 'codex',
32
+ },
33
+ shadow_branch: {
34
+ branch: `nomos/${taskId}`,
35
+ worktree: `/tmp/nomos-worktrees/${taskId}`,
36
+ base_commit: 'abc123',
37
+ status: 'active',
38
+ },
39
+ context: {
40
+ files: [],
41
+ rules: [],
42
+ rules_hash: 'sha256:0000',
43
+ },
44
+ budget: {
45
+ tokens_used: 0,
46
+ estimated_cost_usd: 0,
47
+ },
48
+ history: [],
49
+ };
50
+ }
51
+
52
+ function makeHistoryEntry(version: number = 1): HistoryEntry {
53
+ const now = new Date().toISOString();
54
+ return {
55
+ version,
56
+ step: 'planning',
57
+ mode: 'supervised',
58
+ binary: 'claude',
59
+ started_at: now,
60
+ completed_at: now,
61
+ raw_output: 'some output',
62
+ output_hash: 'sha256:abcdef',
63
+ input_tokens: 100,
64
+ output_tokens: 200,
65
+ tokens_used: 300,
66
+ tokens_source: 'metered',
67
+ rules_snapshot: [],
68
+ review: null,
69
+ };
70
+ }
71
+
72
+ // ── Tests ────────────────────────────────────────────────────────────────────
73
+
74
+ describe('StateManager — create & read', () => {
75
+ let stateDir: string;
76
+ let sm: StateManager;
77
+
78
+ beforeEach(() => {
79
+ stateDir = makeTmpDir();
80
+ sm = new StateManager(stateDir, logger);
81
+ });
82
+
83
+ afterEach(() => {
84
+ fs.rmSync(stateDir, { recursive: true, force: true });
85
+ });
86
+
87
+ it('creates and reads a task state round-trip', async () => {
88
+ const state = makeInitialState('task-001');
89
+ await sm.create('task-001', state);
90
+ const loaded = await sm.read('task-001');
91
+ expect(loaded.task_id).toBe('task-001');
92
+ expect(loaded.meta.status).toBe('init');
93
+ expect(loaded.schema_version).toBe(1);
94
+ });
95
+
96
+ it('throws task_exists when creating a duplicate task', async () => {
97
+ const state = makeInitialState('dup-task');
98
+ await sm.create('dup-task', state);
99
+ await expect(sm.create('dup-task', state)).rejects.toMatchObject({
100
+ code: 'task_exists',
101
+ });
102
+ });
103
+
104
+ it('throws task_not_found when reading a non-existent task', async () => {
105
+ await expect(sm.read('ghost-task')).rejects.toMatchObject({
106
+ code: 'task_not_found',
107
+ });
108
+ });
109
+ });
110
+
111
+ describe('StateManager — atomic write', () => {
112
+ let stateDir: string;
113
+ let sm: StateManager;
114
+
115
+ beforeEach(() => {
116
+ stateDir = makeTmpDir();
117
+ sm = new StateManager(stateDir, logger);
118
+ });
119
+
120
+ afterEach(() => {
121
+ fs.rmSync(stateDir, { recursive: true, force: true });
122
+ });
123
+
124
+ it('atomic write: original file is intact if tmp exists but rename never happened', async () => {
125
+ const state = makeInitialState('atomic-task');
126
+ await sm.create('atomic-task', state);
127
+
128
+ // Simulate a crash: leave a .tmp file behind without renaming
129
+ const filePath = path.join(stateDir, 'atomic-task.json');
130
+ const tmpPath = `${filePath}.tmp`;
131
+ fs.writeFileSync(tmpPath, '{"broken":true}', 'utf-8');
132
+
133
+ // Original must still be intact
134
+ const loaded = await sm.read('atomic-task');
135
+ expect(loaded.task_id).toBe('atomic-task');
136
+ expect(fs.existsSync(tmpPath)).toBe(true); // tmp still there (we didn't clean it)
137
+ });
138
+ });
139
+
140
+ describe('StateManager — stale lock', () => {
141
+ let stateDir: string;
142
+ let sm: StateManager;
143
+
144
+ beforeEach(() => {
145
+ stateDir = makeTmpDir();
146
+ sm = new StateManager(stateDir, logger);
147
+ });
148
+
149
+ afterEach(() => {
150
+ fs.rmSync(stateDir, { recursive: true, force: true });
151
+ });
152
+
153
+ it('write succeeds when a stale lock file exists (mtime backdated 60s)', async () => {
154
+ const state = makeInitialState('stale-task');
155
+ await sm.create('stale-task', state);
156
+
157
+ const filePath = path.join(stateDir, 'stale-task.json');
158
+ const lockPath = `${filePath}.lock`;
159
+
160
+ // Manually create a lock directory and backdate its mtime by 60s
161
+ fs.mkdirSync(lockPath, { recursive: true });
162
+ const sixtySecondsAgo = new Date(Date.now() - 60_000);
163
+ fs.utimesSync(lockPath, sixtySecondsAgo, sixtySecondsAgo);
164
+
165
+ // Write should succeed — proper-lockfile sees the lock as stale and breaks it
166
+ state.meta.status = 'planning';
167
+ await expect(sm.write('stale-task', state)).resolves.toBeUndefined();
168
+ const loaded = await sm.read('stale-task');
169
+ expect(loaded.meta.status).toBe('planning');
170
+ });
171
+ });
172
+
173
+ describe('StateManager — transitions', () => {
174
+ let stateDir: string;
175
+ let sm: StateManager;
176
+
177
+ beforeEach(() => {
178
+ stateDir = makeTmpDir();
179
+ sm = new StateManager(stateDir, logger);
180
+ });
181
+
182
+ afterEach(() => {
183
+ fs.rmSync(stateDir, { recursive: true, force: true });
184
+ });
185
+
186
+ it('valid transition: init → planning → pending_review', async () => {
187
+ await sm.create('fsm-task', makeInitialState('fsm-task'));
188
+
189
+ let state = await sm.transition('fsm-task', 'planning');
190
+ expect(state.meta.status).toBe('planning');
191
+
192
+ state = await sm.transition('fsm-task', 'pending_review');
193
+ expect(state.meta.status).toBe('pending_review');
194
+ });
195
+
196
+ it('invalid transition: merged → planning throws invalid_transition', async () => {
197
+ const initial = makeInitialState('merged-task');
198
+ initial.meta.status = 'merged';
199
+ await sm.create('merged-task', initial);
200
+
201
+ await expect(sm.transition('merged-task', 'planning')).rejects.toMatchObject({
202
+ code: 'invalid_transition',
203
+ });
204
+ });
205
+
206
+ it('transition with version_increment bumps current_version', async () => {
207
+ await sm.create('ver-task', makeInitialState('ver-task'));
208
+ const state = await sm.transition('ver-task', 'planning', { version_increment: true });
209
+ expect(state.current_version).toBe(2);
210
+ });
211
+
212
+ it('transition with history_entry appends to history array', async () => {
213
+ await sm.create('hist-task', makeInitialState('hist-task'));
214
+ const entry = makeHistoryEntry();
215
+ const state = await sm.transition('hist-task', 'planning', { history_entry: entry });
216
+ expect(state.history).toHaveLength(1);
217
+ expect(state.history[0]!.step).toBe('planning');
218
+ expect(state.history[0]!.tokens_source).toBe('metered');
219
+ });
220
+
221
+ it('transition with review_result attaches to last history entry', async () => {
222
+ await sm.create('rev-task', makeInitialState('rev-task'));
223
+ // First add a history entry
224
+ await sm.transition('rev-task', 'planning', { history_entry: makeHistoryEntry() });
225
+ // Now add review result (simulate reviewing → refinement)
226
+ await sm.transition('rev-task', 'pending_review');
227
+ await sm.transition('rev-task', 'reviewing');
228
+ const review: ReviewResult = {
229
+ score: 0.85,
230
+ mode: 'supervised',
231
+ issues: [],
232
+ summary: 'Looks good overall, minor issues found.',
233
+ };
234
+ const state = await sm.transition('rev-task', 'refinement', { review_result: review });
235
+ expect(state.history[0]!.review).toMatchObject({ score: 0.85 });
236
+ });
237
+
238
+ it('transition with approval_reason sets meta.approval_reason', async () => {
239
+ await sm.create('appr-task', makeInitialState('appr-task'));
240
+ await sm.transition('appr-task', 'planning');
241
+ await sm.transition('appr-task', 'pending_review');
242
+ await sm.transition('appr-task', 'reviewing');
243
+ const state = await sm.transition('appr-task', 'approved', {
244
+ approval_reason: 'score_threshold',
245
+ });
246
+ expect(state.meta.approval_reason).toBe('score_threshold');
247
+ });
248
+
249
+ it('valid recovery transitions: stalled → planning, failed → planning, merge_conflict → approved', async () => {
250
+ // stalled → planning
251
+ const s1 = makeInitialState('stalled-task');
252
+ s1.meta.status = 'stalled';
253
+ await sm.create('stalled-task', s1);
254
+ const r1 = await sm.transition('stalled-task', 'planning');
255
+ expect(r1.meta.status).toBe('planning');
256
+
257
+ // failed → planning
258
+ const s2 = makeInitialState('failed-task');
259
+ s2.meta.status = 'failed';
260
+ await sm.create('failed-task', s2);
261
+ const r2 = await sm.transition('failed-task', 'planning');
262
+ expect(r2.meta.status).toBe('planning');
263
+
264
+ // merge_conflict → approved
265
+ const s3 = makeInitialState('conflict-task');
266
+ s3.meta.status = 'merge_conflict';
267
+ await sm.create('conflict-task', s3);
268
+ const r3 = await sm.transition('conflict-task', 'approved');
269
+ expect(r3.meta.status).toBe('approved');
270
+ });
271
+ });
272
+
273
+ describe('StateManager — cleanupTempFiles', () => {
274
+ let stateDir: string;
275
+ let sm: StateManager;
276
+
277
+ beforeEach(() => {
278
+ stateDir = makeTmpDir();
279
+ sm = new StateManager(stateDir, logger);
280
+ });
281
+
282
+ afterEach(() => {
283
+ fs.rmSync(stateDir, { recursive: true, force: true });
284
+ });
285
+
286
+ it('removes .json.tmp files', () => {
287
+ const tmpFile = path.join(stateDir, 'orphan.json.tmp');
288
+ fs.writeFileSync(tmpFile, '{}', 'utf-8');
289
+ expect(fs.existsSync(tmpFile)).toBe(true);
290
+ sm.cleanupTempFiles(stateDir);
291
+ expect(fs.existsSync(tmpFile)).toBe(false);
292
+ });
293
+
294
+ it('does NOT remove .lock files (proper-lockfile owns those)', () => {
295
+ const lockDir = path.join(stateDir, 'some-task.json.lock');
296
+ fs.mkdirSync(lockDir);
297
+ sm.cleanupTempFiles(stateDir);
298
+ expect(fs.existsSync(lockDir)).toBe(true);
299
+ });
300
+ });
301
+
302
+ describe('StateManager — schema migration', () => {
303
+ let stateDir: string;
304
+ let sm: StateManager;
305
+
306
+ beforeEach(() => {
307
+ stateDir = makeTmpDir();
308
+ sm = new StateManager(stateDir, logger);
309
+ });
310
+
311
+ afterEach(() => {
312
+ fs.rmSync(stateDir, { recursive: true, force: true });
313
+ });
314
+
315
+ it('reads state file missing schema_version and sets it to 1', async () => {
316
+ // Write raw JSON without schema_version (simulates pre-v1 state)
317
+ const now = new Date().toISOString();
318
+ const rawState = {
319
+ // schema_version deliberately omitted
320
+ task_id: 'legacy-task',
321
+ current_version: 1,
322
+ meta: { status: 'init', created_at: now, updated_at: now },
323
+ orchestration: { planner_bin: 'claude', reviewer_bin: 'codex' },
324
+ shadow_branch: { branch: 'nomos/legacy-task', worktree: '/tmp/x', base_commit: 'abc', status: 'active' },
325
+ context: { files: [], rules: [], rules_hash: 'sha256:0' },
326
+ budget: { tokens_used: 0, estimated_cost_usd: 0 },
327
+ history: [],
328
+ };
329
+ fs.mkdirSync(stateDir, { recursive: true });
330
+ fs.writeFileSync(path.join(stateDir, 'legacy-task.json'), JSON.stringify(rawState), 'utf-8');
331
+
332
+ const state = await sm.read('legacy-task');
333
+ expect(state.schema_version).toBe(1);
334
+ expect(state.task_id).toBe('legacy-task');
335
+ });
336
+ });
337
+
338
+ describe('StateManager — listTasks', () => {
339
+ let stateDir: string;
340
+ let sm: StateManager;
341
+
342
+ beforeEach(() => {
343
+ stateDir = makeTmpDir();
344
+ sm = new StateManager(stateDir, logger);
345
+ });
346
+
347
+ afterEach(() => {
348
+ fs.rmSync(stateDir, { recursive: true, force: true });
349
+ });
350
+
351
+ it('lists all valid task states and skips .tmp files', async () => {
352
+ await sm.create('list-task-a', makeInitialState('list-task-a'));
353
+ await sm.create('list-task-b', makeInitialState('list-task-b'));
354
+ // Orphan tmp should be ignored
355
+ fs.writeFileSync(path.join(stateDir, 'orphan.json.tmp'), '{}', 'utf-8');
356
+
357
+ const tasks = await sm.listTasks();
358
+ expect(tasks).toHaveLength(2);
359
+ const ids = tasks.map((t) => t.task_id).sort();
360
+ expect(ids).toEqual(['list-task-a', 'list-task-b']);
361
+ });
362
+ });
@@ -0,0 +1,166 @@
1
+ import * as os from 'node:os';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import type { Logger } from 'winston';
6
+ import { AuthManager } from '../manager.js';
7
+ import { NomosError } from '../../errors.js';
8
+ import type { AuthCredentials, NomosConfig } from '../../../types/index.js';
9
+
10
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
11
+
12
+ function makeLogger(): Logger {
13
+ return {
14
+ info: vi.fn(),
15
+ warn: vi.fn(),
16
+ error: vi.fn(),
17
+ debug: vi.fn(),
18
+ } as unknown as Logger;
19
+ }
20
+
21
+ function makeConfig(credentialsPath: string): NomosConfig['auth'] {
22
+ return {
23
+ client_id: 'test-client-id',
24
+ credentials_path: credentialsPath,
25
+ redirect_port: 3000,
26
+ };
27
+ }
28
+
29
+ function makeCredentials(overrides: Partial<AuthCredentials> = {}): AuthCredentials {
30
+ return {
31
+ access_token: 'test-access-token',
32
+ refresh_token: 'test-refresh-token',
33
+ expiry_date: Date.now() + 3600_000,
34
+ token_type: 'Bearer',
35
+ scope: 'https://www.googleapis.com/auth/generative-language.retriever',
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ // ─── Tests ────────────────────────────────────────────────────────────────────
41
+
42
+ describe('AuthManager', () => {
43
+ let tmpDir: string;
44
+ let credentialsPath: string;
45
+ let manager: AuthManager;
46
+
47
+ beforeEach(async () => {
48
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-auth-test-'));
49
+ credentialsPath = path.join(tmpDir, '.nomos', 'credentials.json');
50
+ manager = new AuthManager(makeConfig(credentialsPath), makeLogger());
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await fs.rm(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ // ─── saveCredentials ───────────────────────────────────────────────────────
58
+
59
+ describe('saveCredentials', () => {
60
+ it('writes JSON to the credentials path', async () => {
61
+ const creds = makeCredentials();
62
+ await manager.saveCredentials(creds);
63
+
64
+ const raw = await fs.readFile(credentialsPath, 'utf8');
65
+ const parsed = JSON.parse(raw) as AuthCredentials;
66
+ expect(parsed.access_token).toBe(creds.access_token);
67
+ expect(parsed.refresh_token).toBe(creds.refresh_token);
68
+ expect(parsed.expiry_date).toBe(creds.expiry_date);
69
+ });
70
+
71
+ it('creates parent directories if they do not exist', async () => {
72
+ const nestedPath = path.join(tmpDir, 'a', 'b', 'c', 'credentials.json');
73
+ const nestedManager = new AuthManager(makeConfig(nestedPath), makeLogger());
74
+ await nestedManager.saveCredentials(makeCredentials());
75
+
76
+ const stat = await fs.stat(nestedPath);
77
+ expect(stat.isFile()).toBe(true);
78
+ });
79
+
80
+ it('sets file permissions to 0600', async () => {
81
+ await manager.saveCredentials(makeCredentials());
82
+ const stat = await fs.stat(credentialsPath);
83
+ // eslint-disable-next-line no-bitwise
84
+ expect(stat.mode & 0o777).toBe(0o600);
85
+ });
86
+ });
87
+
88
+ // ─── loadCredentials ───────────────────────────────────────────────────────
89
+
90
+ describe('loadCredentials', () => {
91
+ it('returns parsed credentials when file exists', async () => {
92
+ const creds = makeCredentials();
93
+ await manager.saveCredentials(creds);
94
+
95
+ const loaded = manager.loadCredentials();
96
+ expect(loaded).not.toBeNull();
97
+ expect(loaded!.access_token).toBe(creds.access_token);
98
+ expect(loaded!.refresh_token).toBe(creds.refresh_token);
99
+ });
100
+
101
+ it('returns null when the file does not exist', () => {
102
+ const result = manager.loadCredentials();
103
+ expect(result).toBeNull();
104
+ });
105
+
106
+ it('returns null on invalid JSON', async () => {
107
+ await fs.mkdir(path.dirname(credentialsPath), { recursive: true });
108
+ await fs.writeFile(credentialsPath, 'not valid json', 'utf8');
109
+
110
+ const result = manager.loadCredentials();
111
+ expect(result).toBeNull();
112
+ });
113
+
114
+ it('returns null when JSON is missing required fields', async () => {
115
+ await fs.mkdir(path.dirname(credentialsPath), { recursive: true });
116
+ await fs.writeFile(credentialsPath, JSON.stringify({ access_token: 'only-one-field' }), 'utf8');
117
+
118
+ const result = manager.loadCredentials();
119
+ expect(result).toBeNull();
120
+ });
121
+ });
122
+
123
+ // ─── isLoggedIn ────────────────────────────────────────────────────────────
124
+
125
+ describe('isLoggedIn', () => {
126
+ it('returns true when credentials file has refresh_token', async () => {
127
+ await manager.saveCredentials(makeCredentials({ refresh_token: 'valid-refresh-token' }));
128
+ expect(manager.isLoggedIn()).toBe(true);
129
+ });
130
+
131
+ it('returns false when credentials file is missing', () => {
132
+ expect(manager.isLoggedIn()).toBe(false);
133
+ });
134
+
135
+ it('returns false when refresh_token is empty', async () => {
136
+ await manager.saveCredentials(makeCredentials({ refresh_token: '' }));
137
+ expect(manager.isLoggedIn()).toBe(false);
138
+ });
139
+ });
140
+
141
+ // ─── clearCredentials ──────────────────────────────────────────────────────
142
+
143
+ describe('clearCredentials', () => {
144
+ it('deletes the credentials file', async () => {
145
+ await manager.saveCredentials(makeCredentials());
146
+ await manager.clearCredentials();
147
+
148
+ await expect(fs.stat(credentialsPath)).rejects.toMatchObject({ code: 'ENOENT' });
149
+ });
150
+
151
+ it('does not throw if the credentials file is missing', async () => {
152
+ await expect(manager.clearCredentials()).resolves.toBeUndefined();
153
+ });
154
+ });
155
+
156
+ // ─── getAccessToken ────────────────────────────────────────────────────────
157
+
158
+ describe('getAccessToken', () => {
159
+ it('throws auth_not_logged_in when no credentials exist', async () => {
160
+ await expect(manager.getAccessToken()).rejects.toSatisfy(
161
+ (err: unknown) =>
162
+ err instanceof NomosError && err.code === 'auth_not_logged_in',
163
+ );
164
+ });
165
+ });
166
+ });