@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,213 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { estimateTokens, parseTokensFromOutput, calculateCost, checkBudget } from '../budget.js';
3
+ import type { TaskState, NomosConfig } from '../../types/index.js';
4
+
5
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
6
+
7
+ function makeState(tokens_used: number): TaskState {
8
+ return {
9
+ schema_version: 1,
10
+ task_id: 'task-budget-test',
11
+ current_version: 1,
12
+ meta: {
13
+ status: 'planning',
14
+ created_at: '2026-04-03T00:00:00.000Z',
15
+ updated_at: '2026-04-03T00:00:00.000Z',
16
+ },
17
+ orchestration: { planner_bin: 'claude', reviewer_bin: 'codex' },
18
+ shadow_branch: {
19
+ branch: 'nomos/task-budget-test',
20
+ worktree: '/tmp/nomos-worktrees/proj/task-budget-test',
21
+ base_commit: 'abc123',
22
+ status: 'active',
23
+ },
24
+ context: { files: [], rules: ['global.md'], rules_hash: 'sha256:abc' },
25
+ budget: { tokens_used, estimated_cost_usd: 0 },
26
+ history: [],
27
+ };
28
+ }
29
+
30
+ function makeConfig(max_tokens_per_task: number, warn_at_percent: number): NomosConfig {
31
+ return {
32
+ execution: {
33
+ default_mode: 'supervised',
34
+ shadow_branch_prefix: 'nomos/',
35
+ worktree_base: '/tmp/nomos-worktrees',
36
+ supervised_heartbeat_timeout_ms: 30000,
37
+ },
38
+ binaries: {
39
+ planner: {
40
+ cmd: 'claude', args: [], pty: true,
41
+ total_timeout_ms: 60000, heartbeat_timeout_ms: 30000,
42
+ max_output_bytes: 1048576, usage_pattern: null,
43
+ },
44
+ reviewer: {
45
+ cmd: 'codex', args: [], pty: false,
46
+ total_timeout_ms: 60000, heartbeat_timeout_ms: 30000,
47
+ max_output_bytes: 1048576, usage_pattern: null,
48
+ },
49
+ },
50
+ convergence: { score_threshold: 0.85, max_iterations: 3 },
51
+ budget: {
52
+ max_tokens_per_task,
53
+ warn_at_percent,
54
+ cost_per_1k_tokens: {},
55
+ },
56
+ security: {
57
+ sanitize_patterns: [],
58
+ entropy_threshold: 4.5,
59
+ sanitize_on: ['input', 'output'],
60
+ safe_commands: [],
61
+ redaction_label: '[REDACTED]',
62
+ },
63
+ git: { auto_commit: true, include_logs: false, commit_prefix: 'nomos:', sign_commits: false },
64
+ review: { max_context_files: 5 },
65
+ graph: {
66
+ exclude_patterns: ['node_modules', 'dist'],
67
+ ai_enrichment: true,
68
+ ai_model: 'gemini-1.5-flash',
69
+ ai_concurrency: 5,
70
+ ai_requests_per_minute: 14,
71
+ max_file_chars: 4000,
72
+ core_modules_count: 10,
73
+ output_dir: 'tasks-management/graph',
74
+ },
75
+ logging: { level: 'info', retain_days: 7 },
76
+ search: {
77
+ embedding_model: 'gemini-embedding-001',
78
+ embedding_dimensions: 768,
79
+ vector_store_path: 'tasks-management/graph/vector_index',
80
+ default_top_k: 5,
81
+ default_threshold: 0.7,
82
+ batch_size: 50,
83
+ embedding_requests_per_minute: 300,
84
+ request_timeout_ms: 30000,
85
+ },
86
+ auth: {
87
+ credentials_path: '~/.nomos/credentials.json',
88
+ redirect_port: 3000,
89
+ },
90
+ };
91
+ }
92
+
93
+ // ── estimateTokens ────────────────────────────────────────────────────────────
94
+
95
+ describe('estimateTokens', () => {
96
+ it('returns separate input/output token estimates from character counts', () => {
97
+ const prompt = 'a'.repeat(4000);
98
+ const output = 'b'.repeat(4000);
99
+ const result = estimateTokens(prompt, output);
100
+ expect(result).toEqual({ input_tokens: 1000, output_tokens: 1000, total: 2000 });
101
+ });
102
+
103
+ it('uses Math.ceil so partial chars round up', () => {
104
+ const result = estimateTokens('abc', 'x'); // 3 chars → ceil(0.75)=1, 1 char → 1
105
+ expect(result.input_tokens).toBe(1);
106
+ expect(result.output_tokens).toBe(1);
107
+ expect(result.total).toBe(2);
108
+ });
109
+ });
110
+
111
+ // ── parseTokensFromOutput ─────────────────────────────────────────────────────
112
+
113
+ describe('parseTokensFromOutput', () => {
114
+ it('returns null when usagePattern is null', () => {
115
+ const result = parseTokensFromOutput('Tokens used: 12345', null);
116
+ expect(result).toBeNull();
117
+ });
118
+
119
+ it('returns null when pattern does not match', () => {
120
+ const result = parseTokensFromOutput('No token info here', 'Tokens used:\\s*(\\d+)');
121
+ expect(result).toBeNull();
122
+ });
123
+
124
+ it('applies 90/10 split for single-group pattern', () => {
125
+ // total=12345, input=Math.round(12345*0.9)=11111, output=12345-11111=1234
126
+ const result = parseTokensFromOutput('Tokens used: 12345', 'Tokens used:\\s*(\\d+)');
127
+ expect(result).toEqual({ input_tokens: 11111, output_tokens: 1234, total: 12345 });
128
+ });
129
+
130
+ it('captures separate input/output for two-group pattern', () => {
131
+ const result = parseTokensFromOutput(
132
+ 'Input: 5000 Output: 1000',
133
+ 'Input:\\s*(\\d+).*Output:\\s*(\\d+)',
134
+ );
135
+ expect(result).toEqual({ input_tokens: 5000, output_tokens: 1000, total: 6000 });
136
+ });
137
+ });
138
+
139
+ // ── calculateCost ─────────────────────────────────────────────────────────────
140
+
141
+ describe('calculateCost', () => {
142
+ it('returns 0 for unknown binary cmd', () => {
143
+ const tokens = { input_tokens: 900, output_tokens: 100, total: 1000 };
144
+ const cost = calculateCost(tokens, 'unknown-bin', { claude: 0.015 });
145
+ expect(cost).toBe(0);
146
+ });
147
+
148
+ it('uses split rates when costMap entry is a plain number (backward compat)', () => {
149
+ // Rate = 0.015 per 1K output; input = 0.015/5 = 0.003
150
+ // input cost: (900/1000)*0.003 = 0.0027
151
+ // output cost: (100/1000)*0.015 = 0.0015
152
+ // total: 0.0042 (rounded to 6 decimal places)
153
+ const tokens = { input_tokens: 900, output_tokens: 100, total: 1000 };
154
+ const cost = calculateCost(tokens, 'claude', { claude: 0.015 });
155
+ expect(cost).toBeCloseTo(0.0042, 6);
156
+ // Must NOT equal the flat-rate result (0.015 * 1 = 0.015)
157
+ expect(cost).not.toBeCloseTo(0.015, 6);
158
+ });
159
+
160
+ it('uses explicit input/output rates from object entry', () => {
161
+ // input cost: (900/1000)*0.003 = 0.0027
162
+ // output cost: (100/1000)*0.015 = 0.0015
163
+ // total: 0.0042
164
+ const tokens = { input_tokens: 900, output_tokens: 100, total: 1000 };
165
+ const cost = calculateCost(tokens, 'claude', { claude: { input: 0.003, output: 0.015 } });
166
+ expect(cost).toBeCloseTo(0.0042, 6);
167
+ });
168
+
169
+ it('normalizes absolute path to basename for map lookup', () => {
170
+ const tokens = { input_tokens: 500, output_tokens: 500, total: 1000 };
171
+ const withAbsPath = calculateCost(tokens, '/usr/local/bin/claude', { claude: { input: 0.003, output: 0.015 } });
172
+ const withBasename = calculateCost(tokens, 'claude', { claude: { input: 0.003, output: 0.015 } });
173
+ expect(withAbsPath).toBe(withBasename);
174
+ expect(withAbsPath).toBeGreaterThan(0);
175
+ });
176
+
177
+ it('returns 0 when normalized basename is not in the map (e.g. npx)', () => {
178
+ const tokens = { input_tokens: 900, output_tokens: 100, total: 1000 };
179
+ const cost = calculateCost(tokens, 'npx', { claude: 0.015 });
180
+ expect(cost).toBe(0);
181
+ });
182
+ });
183
+
184
+ // ── checkBudget ───────────────────────────────────────────────────────────────
185
+
186
+ describe('checkBudget', () => {
187
+ it('blocks when tokens_used equals max_tokens_per_task', () => {
188
+ const state = makeState(100000);
189
+ const config = makeConfig(100000, 80);
190
+ const result = checkBudget(state, config);
191
+ expect(result.allowed).toBe(false);
192
+ expect(result.error).toContain('Token budget exceeded');
193
+ expect(result.error).toContain('100000 / 100000');
194
+ });
195
+
196
+ it('warns at 80% of budget', () => {
197
+ const state = makeState(80000);
198
+ const config = makeConfig(100000, 80);
199
+ const result = checkBudget(state, config);
200
+ expect(result.allowed).toBe(true);
201
+ expect(result.warning).toContain('80%');
202
+ expect(result.warning).toContain('80000 / 100000');
203
+ });
204
+
205
+ it('allows without warning below warn threshold', () => {
206
+ const state = makeState(50000);
207
+ const config = makeConfig(100000, 80);
208
+ const result = checkBudget(state, config);
209
+ expect(result.allowed).toBe(true);
210
+ expect(result.warning).toBeUndefined();
211
+ expect(result.error).toBeUndefined();
212
+ });
213
+ });
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createHash } from 'crypto';
3
+ import { CertificateEngine } from '../certificate.js';
4
+ import { NomosError } from '../errors.js';
5
+ import type { TaskState, HistoryEntry, ReviewResult } from '../../types/index.js';
6
+
7
+ // ── Helpers ──────────────────────────────────────────────────────────────────
8
+
9
+ function sha256(data: string): string {
10
+ return `sha256:${createHash('sha256').update(data).digest('hex')}`;
11
+ }
12
+
13
+ function makeReviewResult(score: number): ReviewResult {
14
+ return {
15
+ score,
16
+ mode: 'auto',
17
+ issues: [
18
+ {
19
+ severity: 'low',
20
+ category: 'maintainability',
21
+ description: 'Consider extracting helper function',
22
+ suggestion: 'Move repeated logic into a shared utility',
23
+ },
24
+ ],
25
+ summary: 'Code meets standards with minor suggestions for improvement.',
26
+ };
27
+ }
28
+
29
+ function makePlanningEntry(version: number): HistoryEntry {
30
+ return {
31
+ version,
32
+ step: 'planning',
33
+ mode: 'supervised',
34
+ binary: 'claude',
35
+ started_at: `2026-04-0${version}T10:00:00.000Z`,
36
+ completed_at: `2026-04-0${version}T10:05:00.000Z`,
37
+ raw_output: `planning output v${version}`,
38
+ output_hash: sha256(`planning output v${version}`),
39
+ input_tokens: 1000,
40
+ output_tokens: 2000,
41
+ tokens_used: 3000,
42
+ tokens_source: 'metered',
43
+ rules_snapshot: ['global.md', 'backend.md'],
44
+ review: null,
45
+ };
46
+ }
47
+
48
+ function makeReviewEntry(version: number, score: number): HistoryEntry {
49
+ return {
50
+ version,
51
+ step: 'reviewing',
52
+ mode: 'auto',
53
+ binary: 'codex',
54
+ started_at: `2026-04-0${version}T11:00:00.000Z`,
55
+ completed_at: `2026-04-0${version}T11:03:00.000Z`,
56
+ raw_output: `review output v${version}`,
57
+ output_hash: sha256(`review output v${version}`),
58
+ input_tokens: 500,
59
+ output_tokens: 800,
60
+ tokens_used: 1300,
61
+ tokens_source: 'metered',
62
+ rules_snapshot: ['global.md', 'backend.md'],
63
+ review: makeReviewResult(score),
64
+ };
65
+ }
66
+
67
+ function makeApprovedState(taskId: string): TaskState {
68
+ return {
69
+ schema_version: 1,
70
+ task_id: taskId,
71
+ current_version: 2,
72
+ meta: {
73
+ status: 'approved',
74
+ created_at: '2026-04-01T09:00:00.000Z',
75
+ updated_at: '2026-04-02T11:03:00.000Z',
76
+ approval_reason: 'score_threshold',
77
+ },
78
+ orchestration: {
79
+ planner_bin: 'claude',
80
+ reviewer_bin: 'codex',
81
+ },
82
+ shadow_branch: {
83
+ branch: `nomos/${taskId}`,
84
+ worktree: `/tmp/nomos-worktrees/${taskId}`,
85
+ base_commit: 'a1b2c3d4e5f6',
86
+ status: 'active',
87
+ },
88
+ context: {
89
+ files: ['src/main.ts'],
90
+ rules: ['global.md', 'backend.md'],
91
+ rules_hash: 'sha256:rules000',
92
+ },
93
+ budget: {
94
+ tokens_used: 8600,
95
+ estimated_cost_usd: 0.0215,
96
+ },
97
+ history: [
98
+ makePlanningEntry(1),
99
+ makeReviewEntry(1, 0.65),
100
+ makePlanningEntry(2),
101
+ makeReviewEntry(2, 0.92),
102
+ ],
103
+ };
104
+ }
105
+
106
+ function makeMergedState(taskId: string): TaskState {
107
+ const state = makeApprovedState(taskId);
108
+ state.meta.status = 'merged';
109
+ state.shadow_branch.status = 'merged';
110
+ return state;
111
+ }
112
+
113
+ // ── Tests ────────────────────────────────────────────────────────────────────
114
+
115
+ describe('CertificateEngine — computeChainHash', () => {
116
+ const engine = new CertificateEngine();
117
+
118
+ it('returns deterministic hash for same history', () => {
119
+ const history = [makePlanningEntry(1), makeReviewEntry(1, 0.9)];
120
+ const result1 = engine.computeChainHash(history);
121
+ const result2 = engine.computeChainHash(history);
122
+ expect(result1.chainHash).toBe(result2.chainHash);
123
+ expect(result1.chainHash).toMatch(/^sha256:[a-f0-9]{64}$/);
124
+ });
125
+
126
+ it('handles single entry', () => {
127
+ const history = [makePlanningEntry(1)];
128
+ const result = engine.computeChainHash(history);
129
+ expect(result.chainHash).toMatch(/^sha256:[a-f0-9]{64}$/);
130
+ expect(result.canonicalEntries).toHaveLength(1);
131
+ });
132
+
133
+ it('handles empty history', () => {
134
+ const result = engine.computeChainHash([]);
135
+ expect(result.chainHash).toMatch(/^sha256:[a-f0-9]{64}$/);
136
+ expect(result.canonicalEntries).toHaveLength(0);
137
+ });
138
+
139
+ it('produces different hash when an entry is tampered', () => {
140
+ const history = [makePlanningEntry(1), makeReviewEntry(1, 0.9)];
141
+ const original = engine.computeChainHash(history);
142
+
143
+ const tampered = [...history];
144
+ tampered[0] = { ...tampered[0]!, output_hash: 'sha256:tampered' };
145
+ const after = engine.computeChainHash(tampered);
146
+
147
+ expect(original.chainHash).not.toBe(after.chainHash);
148
+ });
149
+
150
+ it('produces different hash when entry order changes', () => {
151
+ const history = [makePlanningEntry(1), makeReviewEntry(1, 0.9)];
152
+ const reversed = [makeReviewEntry(1, 0.9), makePlanningEntry(1)];
153
+
154
+ const h1 = engine.computeChainHash(history);
155
+ const h2 = engine.computeChainHash(reversed);
156
+ expect(h1.chainHash).not.toBe(h2.chainHash);
157
+ });
158
+ });
159
+
160
+ describe('CertificateEngine — generate', () => {
161
+ const engine = new CertificateEngine();
162
+
163
+ it('generates valid certificate for approved task', () => {
164
+ const state = makeApprovedState('task-001');
165
+ const cert = engine.generate(state);
166
+
167
+ expect(cert.certificate_version).toBe(1);
168
+ expect(cert.task_id).toBe('task-001');
169
+ expect(cert.task_status).toBe('approved');
170
+ expect(cert.models.planner).toBe('claude');
171
+ expect(cert.models.reviewer).toBe('codex');
172
+ expect(cert.iterations).toHaveLength(2);
173
+ expect(cert.final_review.score).toBe(0.92);
174
+ expect(cert.final_review.approval_reason).toBe('score_threshold');
175
+ expect(cert.integrity.chain_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
176
+ expect(cert.integrity.entry_hashes).toHaveLength(4);
177
+ expect(cert.integrity.canonical_entries).toHaveLength(4);
178
+ expect(cert.integrity.chain_algorithm).toBe('sha256-sequential');
179
+ expect(cert.certificate_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
180
+ expect(cert.budget.token_breakdown.input_tokens).toBe(3000);
181
+ expect(cert.budget.token_breakdown.output_tokens).toBe(5600);
182
+ });
183
+
184
+ it('generates valid certificate for merged task', () => {
185
+ const state = makeMergedState('task-002');
186
+ const cert = engine.generate(state);
187
+ expect(cert.task_status).toBe('merged');
188
+ expect(cert.repository.branch_status).toBe('merged');
189
+ });
190
+
191
+ it('throws certificate_not_eligible for init status', () => {
192
+ const state = makeApprovedState('task-003');
193
+ state.meta.status = 'init';
194
+
195
+ expect(() => engine.generate(state)).toThrow(NomosError);
196
+ expect(() => engine.generate(state)).toThrow(/certificate_not_eligible|Cannot generate certificate/);
197
+ });
198
+
199
+ it('throws certificate_not_eligible for planning status', () => {
200
+ const state = makeApprovedState('task-004');
201
+ state.meta.status = 'planning';
202
+ expect(() => engine.generate(state)).toThrow(NomosError);
203
+ });
204
+
205
+ it('throws certificate_not_eligible for discarded status', () => {
206
+ const state = makeApprovedState('task-005');
207
+ state.meta.status = 'discarded';
208
+ expect(() => engine.generate(state)).toThrow(NomosError);
209
+ });
210
+
211
+ it('throws when no review entries exist', () => {
212
+ const state = makeApprovedState('task-006');
213
+ state.history = [makePlanningEntry(1)];
214
+ expect(() => engine.generate(state)).toThrow(/at least one planning and one reviewing/);
215
+ });
216
+
217
+ it('throws when no planning entries exist', () => {
218
+ const state = makeApprovedState('task-007');
219
+ state.history = [makeReviewEntry(1, 0.9)];
220
+ expect(() => engine.generate(state)).toThrow(/at least one planning and one reviewing/);
221
+ });
222
+
223
+ it('throws when last review has no review result', () => {
224
+ const state = makeApprovedState('task-008');
225
+ // Remove review result from last reviewing entry
226
+ const lastReview = state.history.find(
227
+ (e, i) => e.step === 'reviewing' && i === state.history.length - 1,
228
+ );
229
+ if (lastReview) lastReview.review = null;
230
+ // Need to ensure the last reviewing entry specifically has null review
231
+ state.history = [makePlanningEntry(1), { ...makeReviewEntry(1, 0.9), review: null }];
232
+ expect(() => engine.generate(state)).toThrow(/no review result/);
233
+ });
234
+
235
+ it('groups iterations correctly by version', () => {
236
+ const state = makeApprovedState('task-009');
237
+ const cert = engine.generate(state);
238
+
239
+ expect(cert.iterations[0]!.version).toBe(1);
240
+ expect(cert.iterations[0]!.planning.binary).toBe('claude');
241
+ expect(cert.iterations[0]!.review?.binary).toBe('codex');
242
+ expect(cert.iterations[0]!.review?.score).toBe(0.65);
243
+
244
+ expect(cert.iterations[1]!.version).toBe(2);
245
+ expect(cert.iterations[1]!.review?.score).toBe(0.92);
246
+ });
247
+
248
+ it('excludes raw_output from certificate', () => {
249
+ const state = makeApprovedState('task-010');
250
+ const cert = engine.generate(state);
251
+ const json = JSON.stringify(cert);
252
+ expect(json).not.toContain('raw_output');
253
+ expect(json).not.toContain('planning output v');
254
+ });
255
+ });
256
+
257
+ describe('CertificateEngine — verify', () => {
258
+ const engine = new CertificateEngine();
259
+
260
+ it('passes all checks for untampered certificate', () => {
261
+ const state = makeApprovedState('task-v01');
262
+ const cert = engine.generate(state);
263
+ const result = engine.verify(cert);
264
+
265
+ expect(result.valid).toBe(true);
266
+ expect(result.checks).toHaveLength(5);
267
+ expect(result.checks.every(c => c.passed)).toBe(true);
268
+ });
269
+
270
+ it('passes all checks for merged task certificate', () => {
271
+ const state = makeMergedState('task-v02');
272
+ const cert = engine.generate(state);
273
+ const result = engine.verify(cert);
274
+ expect(result.valid).toBe(true);
275
+ });
276
+
277
+ it('fails when certificate_hash is tampered', () => {
278
+ const state = makeApprovedState('task-v03');
279
+ const cert = engine.generate(state);
280
+ cert.certificate_hash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
281
+
282
+ const result = engine.verify(cert);
283
+ expect(result.valid).toBe(false);
284
+
285
+ const hashCheck = result.checks.find(c => c.name === 'certificate_hash');
286
+ expect(hashCheck?.passed).toBe(false);
287
+ });
288
+
289
+ it('fails when chain_hash is tampered', () => {
290
+ const state = makeApprovedState('task-v04');
291
+ const cert = engine.generate(state);
292
+ cert.integrity.chain_hash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
293
+ // Re-seal certificate_hash so only chain_hash check fails
294
+ const { certificate_hash: _, ...payload } = cert;
295
+ cert.certificate_hash = `sha256:${createHash('sha256').update(JSON.stringify(payload, null, 2)).digest('hex')}`;
296
+
297
+ const result = engine.verify(cert);
298
+ expect(result.valid).toBe(false);
299
+
300
+ const chainCheck = result.checks.find(c => c.name === 'chain_hash');
301
+ expect(chainCheck?.passed).toBe(false);
302
+ });
303
+
304
+ it('fails when iteration output_hash is tampered', () => {
305
+ const state = makeApprovedState('task-v05');
306
+ const cert = engine.generate(state);
307
+ cert.iterations[0]!.planning.output_hash = 'sha256:tampered';
308
+ // This breaks entry_hash_consistency (iteration data vs canonical entries)
309
+ // and also certificate_hash (payload changed)
310
+
311
+ const result = engine.verify(cert);
312
+ expect(result.valid).toBe(false);
313
+ });
314
+
315
+ it('fails for non-eligible status', () => {
316
+ const state = makeApprovedState('task-v06');
317
+ const cert = engine.generate(state);
318
+ // Force-override status (bypassing generate's check)
319
+ (cert as any).task_status = 'planning';
320
+ // Re-seal
321
+ const { certificate_hash: _, ...payload } = cert;
322
+ cert.certificate_hash = `sha256:${createHash('sha256').update(JSON.stringify(payload, null, 2)).digest('hex')}`;
323
+
324
+ const result = engine.verify(cert);
325
+ expect(result.valid).toBe(false);
326
+
327
+ const statusCheck = result.checks.find(c => c.name === 'status_validity');
328
+ expect(statusCheck?.passed).toBe(false);
329
+ });
330
+
331
+ it('reports ALL failing checks, not just the first', () => {
332
+ const state = makeApprovedState('task-v07');
333
+ const cert = engine.generate(state);
334
+
335
+ // Tamper multiple things
336
+ (cert as any).task_status = 'init';
337
+ cert.integrity.chain_hash = 'sha256:bad';
338
+ cert.certificate_hash = 'sha256:bad';
339
+
340
+ const result = engine.verify(cert);
341
+ expect(result.valid).toBe(false);
342
+
343
+ const failedChecks = result.checks.filter(c => !c.passed);
344
+ expect(failedChecks.length).toBeGreaterThanOrEqual(3);
345
+ });
346
+ });
347
+
348
+ describe('CertificateEngine — parse', () => {
349
+ const engine = new CertificateEngine();
350
+
351
+ it('parses valid certificate JSON', () => {
352
+ const state = makeApprovedState('task-p01');
353
+ const cert = engine.generate(state);
354
+ const json = JSON.stringify(cert, null, 2);
355
+
356
+ const parsed = engine.parse(json);
357
+ expect(parsed.task_id).toBe('task-p01');
358
+ expect(parsed.certificate_version).toBe(1);
359
+ });
360
+
361
+ it('throws certificate_invalid for malformed JSON', () => {
362
+ expect(() => engine.parse('not json')).toThrow(NomosError);
363
+ expect(() => engine.parse('not json')).toThrow(/not valid JSON/);
364
+ });
365
+
366
+ it('throws certificate_invalid for missing fields', () => {
367
+ expect(() => engine.parse('{"task_id": "x"}')).toThrow(NomosError);
368
+ expect(() => engine.parse('{"task_id": "x"}')).toThrow(/validation failed/);
369
+ });
370
+ });
371
+
372
+ describe('CertificateEngine — round-trip integrity', () => {
373
+ const engine = new CertificateEngine();
374
+
375
+ it('generate → JSON → parse → verify is fully consistent', () => {
376
+ const state = makeApprovedState('task-rt1');
377
+ const cert = engine.generate(state);
378
+ const json = JSON.stringify(cert, null, 2);
379
+ const parsed = engine.parse(json);
380
+ const result = engine.verify(parsed);
381
+
382
+ expect(result.valid).toBe(true);
383
+ expect(result.checks.every(c => c.passed)).toBe(true);
384
+ });
385
+ });