@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
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@nomos-arc/arc",
3
+ "version": "0.1.0",
4
+ "description": "The Architect — AI Orchestrator CLI for Verification Architecture",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.0.0"
11
+ },
12
+ "bin": {
13
+ "arc": "./dist/cli.js"
14
+ },
15
+ "scripts": {
16
+ "build": "esbuild src/cli.ts --bundle --platform=node --target=node20 --outfile=dist/cli.js --format=esm --external:node-pty --external:simple-git --external:proper-lockfile --external:winston --external:gray-matter --external:commander --external:@google/generative-ai --external:open --external:google-auth-library --external:fast-glob --external:gitignore-parser --external:web-tree-sitter --external:tree-sitter-wasms --external:@lancedb/lancedb --external:apache-arrow --banner:js='#!/usr/bin/env node'",
17
+ "dev": "tsx src/cli.ts",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:unit": "vitest run --dir src",
21
+ "test:e2e": "vitest run --dir test",
22
+ "lint": "tsc --noEmit"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/i-magdy/nomos-arc.git"
27
+ },
28
+ "keywords": [],
29
+ "author": "Ibrahim Magdy",
30
+ "license": "Apache-2.0",
31
+ "bugs": {
32
+ "url": "https://github.com/i-magdy/nomos-arc/issues"
33
+ },
34
+ "homepage": "https://github.com/i-magdy/nomos-arc#readme",
35
+ "dependencies": {
36
+ "@google/generative-ai": "^0.24.1",
37
+ "@lancedb/lancedb": "0.27.2",
38
+ "apache-arrow": "18.1.0",
39
+ "commander": "^14.0.3",
40
+ "cytoscape": "^3.33.1",
41
+ "fast-glob": "^3.3.3",
42
+ "gitignore-parser": "^0.0.2",
43
+ "google-auth-library": "^10.6.2",
44
+ "gray-matter": "^4.0.3",
45
+ "node-pty": "^1.1.0",
46
+ "open": "^11.0.0",
47
+ "p-limit": "^7.3.0",
48
+ "proper-lockfile": "^4.1.2",
49
+ "simple-git": "^3.33.0",
50
+ "tree-sitter-wasms": "^0.1.13",
51
+ "web-tree-sitter": "^0.24.7",
52
+ "winston": "^3.19.0",
53
+ "zod": "^4.3.6"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^25.5.2",
57
+ "@types/proper-lockfile": "^4.1.4",
58
+ "esbuild": "^0.27.7",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "^6.0.2",
61
+ "vitest": "^4.1.2"
62
+ }
63
+ }
@@ -0,0 +1,296 @@
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 { simpleGit } from 'simple-git';
6
+ import { GitAdapter } from '../git.js';
7
+ import { NomosError } from '../../core/errors.js';
8
+ import { getDefaultConfig } from '../../core/config.js';
9
+ import { createLogger } from '../../core/logger.js';
10
+
11
+ // Minimal logger for tests
12
+ const logger = createLogger('error');
13
+
14
+ function makeConfig(overrides: Partial<{ worktree_base: string }> = {}) {
15
+ const cfg = getDefaultConfig();
16
+ if (overrides.worktree_base) {
17
+ cfg.execution.worktree_base = overrides.worktree_base;
18
+ }
19
+ return cfg;
20
+ }
21
+
22
+ async function initRepo(dir: string): Promise<void> {
23
+ const git = simpleGit(dir);
24
+ await git.init(['-b', 'main']);
25
+ await git.addConfig('user.email', 'test@test.com');
26
+ await git.addConfig('user.name', 'Test User');
27
+ fs.writeFileSync(path.join(dir, 'README.md'), '# test');
28
+ await git.add('README.md');
29
+ await git.commit('Initial commit');
30
+ }
31
+
32
+ async function cleanupWorktrees(repoDir: string): Promise<void> {
33
+ try {
34
+ const git = simpleGit(repoDir);
35
+ const list = await git.raw(['worktree', 'list', '--porcelain']);
36
+ const worktrees = list
37
+ .split('\n\n')
38
+ .slice(1) // skip the main worktree
39
+ .map(block => {
40
+ const match = block.match(/^worktree (.+)/m);
41
+ return match ? match[1].trim() : null;
42
+ })
43
+ .filter(Boolean) as string[];
44
+
45
+ for (const wt of worktrees) {
46
+ await git.raw(['worktree', 'remove', wt, '--force']).catch(() => {});
47
+ }
48
+ } catch {
49
+ // best effort
50
+ }
51
+ }
52
+
53
+ describe('GitAdapter', () => {
54
+ let tmpDir: string;
55
+ let worktreeBase: string;
56
+ let adapter: GitAdapter;
57
+
58
+ beforeEach(async () => {
59
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-git-test-'));
60
+ worktreeBase = fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-wt-base-'));
61
+ await initRepo(tmpDir);
62
+ const config = makeConfig({ worktree_base: worktreeBase });
63
+ adapter = new GitAdapter(tmpDir, config, logger);
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await cleanupWorktrees(tmpDir);
68
+ fs.rmSync(tmpDir, { recursive: true, force: true });
69
+ fs.rmSync(worktreeBase, { recursive: true, force: true });
70
+ });
71
+
72
+ // ── resolveWorktreePath ──────────────────────────────────────────────────────
73
+
74
+ it('resolveWorktreePath produces correct path', () => {
75
+ const taskId = 'my-task';
76
+ const expected = path.join(worktreeBase, path.basename(tmpDir), taskId);
77
+ expect(adapter.resolveWorktreePath(taskId)).toBe(expected);
78
+ });
79
+
80
+ // ── createWorktree ───────────────────────────────────────────────────────────
81
+
82
+ it('createWorktree: branch exists, worktree path exists', async () => {
83
+ const { branch, worktreePath, baseCommit } = await adapter.createWorktree('task-create');
84
+
85
+ expect(branch).toContain('task-create');
86
+ expect(fs.existsSync(worktreePath)).toBe(true);
87
+ expect(baseCommit).toMatch(/^[a-f0-9]{40}$/);
88
+ });
89
+
90
+ it('createWorktree throws branch_exists when branch already exists', async () => {
91
+ await adapter.createWorktree('task-dup');
92
+ await expect(adapter.createWorktree('task-dup')).rejects.toMatchObject({
93
+ code: 'branch_exists',
94
+ });
95
+ });
96
+
97
+ // ── removeWorktree ───────────────────────────────────────────────────────────
98
+
99
+ it('removeWorktree: path and branch cleaned up', async () => {
100
+ const { worktreePath } = await adapter.createWorktree('task-remove');
101
+ await adapter.removeWorktree('task-remove', true);
102
+
103
+ expect(fs.existsSync(worktreePath)).toBe(false);
104
+
105
+ const git = simpleGit(tmpDir);
106
+ const branches = await git.branch(['--list', adapter.resolveBranchName('task-remove')]);
107
+ expect(branches.all).toHaveLength(0);
108
+ });
109
+
110
+ // ── getDiff ─────────────────────────────────────────────────────────────────
111
+
112
+ it('getDiff includes changes from multiple commits (not just HEAD~1)', async () => {
113
+ const { worktreePath, baseCommit } = await adapter.createWorktree('task-diff');
114
+ const wtGit = simpleGit(worktreePath);
115
+
116
+ // First commit in worktree
117
+ fs.writeFileSync(path.join(worktreePath, 'file1.ts'), 'const x = 1;');
118
+ await wtGit.add('file1.ts');
119
+ await wtGit.commit('Add file1');
120
+
121
+ // Second commit in worktree
122
+ fs.writeFileSync(path.join(worktreePath, 'file2.ts'), 'const y = 2;');
123
+ await wtGit.add('file2.ts');
124
+ await wtGit.commit('Add file2');
125
+
126
+ const diff = await adapter.getDiff('task-diff', baseCommit);
127
+ expect(diff).toContain('file1.ts');
128
+ expect(diff).toContain('file2.ts');
129
+ });
130
+
131
+ it('getDiff throws base_commit_unreachable for a fabricated SHA', async () => {
132
+ await adapter.createWorktree('task-bad-sha');
133
+ const fakeSha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
134
+ await expect(adapter.getDiff('task-bad-sha', fakeSha)).rejects.toMatchObject({
135
+ code: 'base_commit_unreachable',
136
+ });
137
+ });
138
+
139
+ // ── commitToShadowBranch ────────────────────────────────────────────────────
140
+
141
+ it('commitToShadowBranch copies files and commits them', async () => {
142
+ // Pre-stage the plan file in the main repo so it's tracked (not untracked)
143
+ const planFile = path.join('tasks-management', 'plans', 'task-commit-v1.md');
144
+ fs.mkdirSync(path.join(tmpDir, 'tasks-management', 'plans'), { recursive: true });
145
+ fs.writeFileSync(path.join(tmpDir, planFile), '# Plan v1');
146
+ const mainGit = simpleGit(tmpDir);
147
+ await mainGit.add(planFile);
148
+ await mainGit.commit('track plan in main');
149
+
150
+ // Now create worktree (branched from HEAD which includes the plan file)
151
+ await adapter.createWorktree('task-commit');
152
+
153
+ // Update the plan file in project root (simulate planner output)
154
+ fs.writeFileSync(path.join(tmpDir, planFile), '# Plan v1 updated');
155
+
156
+ await adapter.commitToShadowBranch('task-commit', 'chore: add plan', [planFile]);
157
+
158
+ const worktreePath = adapter.resolveWorktreePath('task-commit');
159
+ const committedContent = fs.readFileSync(path.join(worktreePath, planFile), 'utf8');
160
+ expect(committedContent).toBe('# Plan v1 updated');
161
+
162
+ const wtGit = simpleGit(worktreePath);
163
+ const log = await wtGit.log();
164
+ expect(log.latest?.message).toBe('chore: add plan');
165
+ });
166
+
167
+ it('commitToShadowBranch throws path_traversal for ../../etc/passwd', async () => {
168
+ await adapter.createWorktree('task-traversal');
169
+ await expect(
170
+ adapter.commitToShadowBranch('task-traversal', 'bad', ['../../etc/passwd']),
171
+ ).rejects.toMatchObject({ code: 'path_traversal' });
172
+ });
173
+
174
+ // ── mergeToMain ─────────────────────────────────────────────────────────────
175
+
176
+ it('mergeToMain performs a clean merge', async () => {
177
+ const { worktreePath } = await adapter.createWorktree('task-merge');
178
+
179
+ // Commit a new file directly in the worktree (no project root pollution)
180
+ fs.writeFileSync(path.join(worktreePath, 'plan.md'), '# Plan');
181
+ const wtGit = simpleGit(worktreePath);
182
+ await wtGit.add('plan.md');
183
+ await wtGit.commit('add plan');
184
+
185
+ const result = await adapter.mergeToMain('task-merge', 1, '[nomos]', 'main');
186
+ expect(result.success).toBe(true);
187
+ });
188
+
189
+ it('mergeToMain with conflict returns success: false and conflict list', async () => {
190
+ // Create conflicting files in both branches
191
+ await adapter.createWorktree('task-conflict');
192
+ const worktreePath = adapter.resolveWorktreePath('task-conflict');
193
+
194
+ const conflictFile = 'conflict.txt';
195
+
196
+ // Write to main branch
197
+ fs.writeFileSync(path.join(tmpDir, conflictFile), 'main content');
198
+ const mainGit = simpleGit(tmpDir);
199
+ await mainGit.add(conflictFile);
200
+ await mainGit.commit('main adds conflict.txt');
201
+
202
+ // Write conflicting content in shadow branch
203
+ fs.writeFileSync(path.join(worktreePath, conflictFile), 'shadow content');
204
+ const wtGit = simpleGit(worktreePath);
205
+ await wtGit.add(conflictFile);
206
+ await wtGit.commit('shadow adds conflict.txt');
207
+
208
+ const result = await adapter.mergeToMain('task-conflict', 1, '[nomos]', 'main');
209
+ expect(result.success).toBe(false);
210
+ expect(result.conflicts).toBeDefined();
211
+ expect(result.conflicts!.length).toBeGreaterThan(0);
212
+ });
213
+
214
+ it('mergeToMain throws wrong_branch when on a different branch', async () => {
215
+ await adapter.createWorktree('task-wrong-branch');
216
+ const mainGit = simpleGit(tmpDir);
217
+ // Checkout a different branch (not the shadow branch, which is checked out in a worktree)
218
+ await mainGit.checkoutLocalBranch('other-branch');
219
+
220
+ await expect(
221
+ adapter.mergeToMain('task-wrong-branch', 1, '[nomos]', 'main'),
222
+ ).rejects.toMatchObject({ code: 'wrong_branch' });
223
+
224
+ // Restore main
225
+ await mainGit.checkout('main');
226
+ });
227
+
228
+ it('mergeToMain throws dirty_working_tree when changes are uncommitted', async () => {
229
+ await adapter.createWorktree('task-dirty');
230
+ // Create an uncommitted change in project root
231
+ fs.writeFileSync(path.join(tmpDir, 'dirty.txt'), 'dirty');
232
+
233
+ await expect(
234
+ adapter.mergeToMain('task-dirty', 1, '[nomos]', 'main'),
235
+ ).rejects.toMatchObject({ code: 'dirty_working_tree' });
236
+
237
+ // Clean up
238
+ fs.unlinkSync(path.join(tmpDir, 'dirty.txt'));
239
+ });
240
+
241
+ // ── recoverWorktree ─────────────────────────────────────────────────────────
242
+
243
+ it('recoverWorktree recreates worktree from existing branch', async () => {
244
+ const { branch, worktreePath } = await adapter.createWorktree('task-recover');
245
+
246
+ // Remove the worktree dir but keep the branch
247
+ const git = simpleGit(tmpDir);
248
+ await git.raw(['worktree', 'remove', worktreePath, '--force']);
249
+ expect(fs.existsSync(worktreePath)).toBe(false);
250
+
251
+ // Recover
252
+ const recovered = await adapter.recoverWorktree('task-recover', branch);
253
+ expect(fs.existsSync(recovered)).toBe(true);
254
+ });
255
+
256
+ it('recoverWorktree throws worktree_unrecoverable when branch is also gone', async () => {
257
+ await expect(
258
+ adapter.recoverWorktree('task-gone', 'nomos/task-gone'),
259
+ ).rejects.toMatchObject({ code: 'worktree_unrecoverable' });
260
+ });
261
+
262
+ // ── grep ────────────────────────────────────────────────────────────────────
263
+
264
+ it('grep returns relative paths matching a pattern', async () => {
265
+ // Create a file in the repo containing a searchable pattern
266
+ fs.writeFileSync(path.join(tmpDir, 'search-me.ts'), 'export const SECRET_PATTERN = true;');
267
+ const git = simpleGit(tmpDir);
268
+ await git.add('search-me.ts');
269
+ await git.commit('Add searchable file');
270
+
271
+ const results = await adapter.grep('SECRET_PATTERN', tmpDir);
272
+ expect(results).toContain('search-me.ts');
273
+ });
274
+
275
+ it('grep returns empty array when no matches found', async () => {
276
+ const results = await adapter.grep('NONEXISTENT_XYZ_12345', tmpDir);
277
+ expect(results).toEqual([]);
278
+ });
279
+
280
+ it('grep returns empty array on timeout', async () => {
281
+ // Use 1ms timeout — will almost certainly time out
282
+ const results = await adapter.grep('.*', tmpDir, 1);
283
+ expect(Array.isArray(results)).toBe(true);
284
+ });
285
+
286
+ // ── isGitRepo / getCurrentCommit ────────────────────────────────────────────
287
+
288
+ it('isGitRepo returns true for a git repo', async () => {
289
+ expect(await adapter.isGitRepo()).toBe(true);
290
+ });
291
+
292
+ it('getCurrentCommit returns a full SHA', async () => {
293
+ const sha = await adapter.getCurrentCommit();
294
+ expect(sha).toMatch(/^[a-f0-9]{40}$/);
295
+ });
296
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as process from 'process';
3
+ import { StdioAdapter } from '../stdio.js';
4
+ import { createLogger } from '../../core/logger.js';
5
+
6
+ const logger = createLogger('error');
7
+ const adapter = new StdioAdapter(logger);
8
+
9
+ const BASE_OPTIONS = {
10
+ cwd: process.cwd(),
11
+ env: process.env as Record<string, string>,
12
+ heartbeat_timeout_ms: 10000,
13
+ total_timeout_ms: 10000,
14
+ max_output_bytes: 1048576,
15
+ };
16
+
17
+ const NODE = process.execPath;
18
+
19
+ describe('StdioAdapter', () => {
20
+ it('captures stdout output from a subprocess', async () => {
21
+ const result = await adapter.execute({
22
+ ...BASE_OPTIONS,
23
+ cmd: NODE,
24
+ args: ['-e', "process.stdout.write('hello world')"],
25
+ stdinData: '',
26
+ });
27
+
28
+ expect(result.exitCode).toBe(0);
29
+ expect(result.rawOutput).toContain('hello world');
30
+ expect(result.killed).toBe(false);
31
+ });
32
+
33
+ it('pipes stdinData to the subprocess and captures output', async () => {
34
+ const result = await adapter.execute({
35
+ ...BASE_OPTIONS,
36
+ cmd: NODE,
37
+ args: ['-e', 'process.stdin.pipe(process.stdout)'],
38
+ stdinData: 'piped input content',
39
+ });
40
+
41
+ expect(result.exitCode).toBe(0);
42
+ expect(result.rawOutput).toContain('piped input content');
43
+ });
44
+
45
+ it('kills a long-running process on total_timeout_ms', async () => {
46
+ const result = await adapter.execute({
47
+ ...BASE_OPTIONS,
48
+ cmd: NODE,
49
+ args: ['-e', 'setTimeout(() => {}, 60000)'],
50
+ stdinData: '',
51
+ total_timeout_ms: 500,
52
+ heartbeat_timeout_ms: 10000,
53
+ });
54
+
55
+ expect(result.killed).toBe(true);
56
+ expect(result.killReason).toBe('total_timeout');
57
+ });
58
+
59
+ it('propagates non-zero exit codes', async () => {
60
+ const result = await adapter.execute({
61
+ ...BASE_OPTIONS,
62
+ cmd: NODE,
63
+ args: ['-e', 'process.exit(42)'],
64
+ stdinData: '',
65
+ });
66
+
67
+ expect(result.exitCode).toBe(42);
68
+ expect(result.killed).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,226 @@
1
+ import { simpleGit, type SimpleGit } from 'simple-git';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import type { Logger } from 'winston';
6
+ import { NomosError } from '../core/errors.js';
7
+ import type { NomosConfig } from '../types/index.js';
8
+
9
+ export class GitAdapter {
10
+ private readonly git: SimpleGit;
11
+
12
+ constructor(
13
+ private readonly projectRoot: string,
14
+ private readonly config: NomosConfig,
15
+ private readonly logger: Logger,
16
+ ) {
17
+ this.git = simpleGit(projectRoot);
18
+ }
19
+
20
+ resolveWorktreePath(taskId: string): string {
21
+ const projectName = path.basename(this.projectRoot);
22
+ const worktreeBase =
23
+ process.platform === 'win32'
24
+ ? path.join(process.env.LOCALAPPDATA ?? os.tmpdir(), 'nomos-worktrees')
25
+ : this.config.execution.worktree_base;
26
+ return path.join(worktreeBase, projectName, taskId);
27
+ }
28
+
29
+ resolveBranchName(taskId: string): string {
30
+ return `${this.config.execution.shadow_branch_prefix}${taskId}`;
31
+ }
32
+
33
+ async createWorktree(
34
+ taskId: string,
35
+ ): Promise<{ branch: string; worktreePath: string; baseCommit: string }> {
36
+ const branchName = this.resolveBranchName(taskId);
37
+ const worktreePath = this.resolveWorktreePath(taskId);
38
+
39
+ fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
40
+
41
+ const existing = await this.git.branch(['--list', branchName]);
42
+ if (existing.all.length > 0) {
43
+ throw new NomosError(
44
+ 'branch_exists',
45
+ `Branch "${branchName}" already exists. Run: arc discard ${taskId}`,
46
+ );
47
+ }
48
+
49
+ await this.git.raw(['worktree', 'add', worktreePath, '-b', branchName]);
50
+
51
+ const baseCommit = (await this.git.revparse(['HEAD'])).trim();
52
+
53
+ this.logger.debug(`Created worktree at ${worktreePath} on branch ${branchName}`);
54
+
55
+ return { branch: branchName, worktreePath, baseCommit };
56
+ }
57
+
58
+ async recoverWorktree(taskId: string, existingBranch: string): Promise<string> {
59
+ const worktreePath = this.resolveWorktreePath(taskId);
60
+
61
+ const existing = await this.git.branch(['--list', existingBranch]);
62
+ if (existing.all.length === 0) {
63
+ throw new NomosError(
64
+ 'worktree_unrecoverable',
65
+ `Cannot recover worktree for "${taskId}": branch "${existingBranch}" no longer exists.`,
66
+ );
67
+ }
68
+
69
+ await this.git.raw(['worktree', 'add', worktreePath, existingBranch]);
70
+
71
+ this.logger.debug(`Recovered worktree at ${worktreePath} from branch ${existingBranch}`);
72
+
73
+ return worktreePath;
74
+ }
75
+
76
+ async removeWorktree(taskId: string, force: boolean = false): Promise<void> {
77
+ const worktreePath = this.resolveWorktreePath(taskId);
78
+ const branchName = this.resolveBranchName(taskId);
79
+
80
+ await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
81
+ await this.git.branch([force ? '-D' : '-d', branchName]);
82
+
83
+ this.logger.debug(`Removed worktree at ${worktreePath} and branch ${branchName}`);
84
+ }
85
+
86
+ worktreeExists(taskId: string): boolean {
87
+ return fs.existsSync(this.resolveWorktreePath(taskId));
88
+ }
89
+
90
+ async grep(pattern: string, cwd: string, timeoutMs: number = 5000): Promise<string[]> {
91
+ const searchGit = simpleGit(cwd);
92
+ try {
93
+ const result = await Promise.race([
94
+ searchGit.raw(['grep', '-l', '-E', pattern]),
95
+ new Promise<never>((_, reject) =>
96
+ setTimeout(() => reject(new Error('grep timeout')), timeoutMs),
97
+ ),
98
+ ]);
99
+ return result.trim().split('\n').filter(Boolean);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ async getDiff(taskId: string, baseCommit: string): Promise<string> {
106
+ const worktreePath = this.resolveWorktreePath(taskId);
107
+ const worktreeGit = simpleGit(worktreePath);
108
+
109
+ // RT2-2.1 fix: verify baseCommit is reachable before diff
110
+ try {
111
+ await worktreeGit.raw(['cat-file', '-t', baseCommit]);
112
+ } catch {
113
+ throw new NomosError(
114
+ 'base_commit_unreachable',
115
+ `Base commit ${baseCommit.slice(0, 8)} is no longer reachable (likely due to rebase or force-push). ` +
116
+ `Run: arc discard ${taskId} && arc init ${taskId} to reinitialize.`,
117
+ );
118
+ }
119
+
120
+ const diff = await worktreeGit.diff([`${baseCommit}..HEAD`, '--', '.']);
121
+ return diff;
122
+ }
123
+
124
+ async commitToShadowBranch(
125
+ taskId: string,
126
+ message: string,
127
+ files: string[],
128
+ ): Promise<void> {
129
+ const worktreePath = this.resolveWorktreePath(taskId);
130
+ const worktreeGit = simpleGit(worktreePath);
131
+
132
+ for (const file of files) {
133
+ const src = path.resolve(this.projectRoot, file);
134
+ const dst = path.resolve(worktreePath, file);
135
+
136
+ // W6: Path traversal defense
137
+ if (
138
+ !dst.startsWith(path.resolve(worktreePath) + path.sep) &&
139
+ dst !== path.resolve(worktreePath)
140
+ ) {
141
+ throw new NomosError(
142
+ 'path_traversal',
143
+ `File path "${file}" resolves outside worktree`,
144
+ );
145
+ }
146
+ if (
147
+ !src.startsWith(path.resolve(this.projectRoot) + path.sep) &&
148
+ src !== path.resolve(this.projectRoot)
149
+ ) {
150
+ throw new NomosError(
151
+ 'path_traversal',
152
+ `File path "${file}" resolves outside project root`,
153
+ );
154
+ }
155
+
156
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
157
+ fs.copyFileSync(src, dst);
158
+ }
159
+
160
+ await worktreeGit.add(files);
161
+
162
+ // M-3 fix: Check git identity before commit to avoid cryptic "Author identity unknown"
163
+ const email = await worktreeGit.raw(['config', '--get', 'user.email']).catch(() => '');
164
+ if (!email.trim()) {
165
+ throw new NomosError(
166
+ 'config_invalid',
167
+ 'Git user identity not configured in the worktree or globally. Run:\n' +
168
+ ' git config --global user.email "you@example.com"\n' +
169
+ ' git config --global user.name "Your Name"',
170
+ );
171
+ }
172
+
173
+ await worktreeGit.commit(message);
174
+ }
175
+
176
+ async mergeToMain(
177
+ taskId: string,
178
+ version: number,
179
+ commitPrefix: string,
180
+ targetBranch: string = 'main',
181
+ ): Promise<{ success: boolean; conflicts?: string[] }> {
182
+ // W3 fix: Target branch verification
183
+ const currentBranch = (await this.git.branch()).current;
184
+ if (currentBranch !== targetBranch) {
185
+ throw new NomosError(
186
+ 'wrong_branch',
187
+ `Cannot apply: expected to be on "${targetBranch}" but currently on "${currentBranch}". ` +
188
+ `Switch first: git checkout ${targetBranch}`,
189
+ );
190
+ }
191
+
192
+ // W7 fix: Dirty working tree check
193
+ const status = await this.git.status();
194
+ if (!status.isClean()) {
195
+ throw new NomosError(
196
+ 'dirty_working_tree',
197
+ 'Cannot apply: working tree has uncommitted changes. Commit or stash them first.',
198
+ );
199
+ }
200
+
201
+ const branchName = this.resolveBranchName(taskId);
202
+ const mergeMessage = `${commitPrefix} apply(${taskId}): merge approved plan v${version}`;
203
+
204
+ try {
205
+ await this.git.merge([branchName, '--no-ff', '-m', mergeMessage]);
206
+ return { success: true };
207
+ } catch {
208
+ const mergeStatus = await this.git.status();
209
+ const conflicts = mergeStatus.conflicted;
210
+ await this.git.merge(['--abort']);
211
+ return { success: false, conflicts };
212
+ }
213
+ }
214
+
215
+ async isGitRepo(): Promise<boolean> {
216
+ return this.git.checkIsRepo();
217
+ }
218
+
219
+ async getCurrentCommit(): Promise<string> {
220
+ return (await this.git.revparse(['HEAD'])).trim();
221
+ }
222
+
223
+ async raw(args: string[]): Promise<string> {
224
+ return this.git.raw(args);
225
+ }
226
+ }