ashlrcode 1.0.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Remote Managed Settings — poll a server for configuration overrides.
3
+ * Supports killswitches, model overrides, and feature flag updates.
4
+ */
5
+
6
+ import { existsSync } from "fs";
7
+ import { readFile, writeFile, mkdir } from "fs/promises";
8
+ import { join } from "path";
9
+ import { getConfigDir } from "./settings.ts";
10
+ import { setFeature } from "./features.ts";
11
+
12
+ export interface RemoteSettings {
13
+ version: number;
14
+ features?: Record<string, boolean>;
15
+ modelOverride?: string;
16
+ effortOverride?: "low" | "normal" | "high";
17
+ killswitches?: {
18
+ bypassPermissions?: boolean;
19
+ voiceMode?: boolean;
20
+ kairosMode?: boolean;
21
+ teamMode?: boolean;
22
+ };
23
+ message?: string; // Display to user on next startup
24
+ fetchedAt: string;
25
+ }
26
+
27
+ const DEFAULT_POLL_INTERVAL = 60 * 60 * 1000; // 1 hour
28
+ const FETCH_TIMEOUT = 10_000; // 10s
29
+
30
+ let _remoteUrl: string | null = null;
31
+ let _apiKey: string | null = null;
32
+ let _pollTimer: ReturnType<typeof setInterval> | null = null;
33
+ let _currentSettings: RemoteSettings | null = null;
34
+ let _onSettingsUpdate: ((settings: RemoteSettings) => void) | null = null;
35
+
36
+ function getCachePath(): string {
37
+ return join(getConfigDir(), "remote-settings.json");
38
+ }
39
+
40
+ /** Initialize remote settings with endpoint and API key */
41
+ export function initRemoteSettings(
42
+ url: string,
43
+ apiKey: string,
44
+ onUpdate?: (settings: RemoteSettings) => void,
45
+ ): void {
46
+ _remoteUrl = url;
47
+ _apiKey = apiKey;
48
+ _onSettingsUpdate = onUpdate ?? null;
49
+ }
50
+
51
+ /** Start polling for remote settings */
52
+ export function startPolling(
53
+ intervalMs: number = DEFAULT_POLL_INTERVAL,
54
+ ): void {
55
+ if (_pollTimer || !_remoteUrl) return;
56
+
57
+ // Fetch immediately, then poll
58
+ fetchRemoteSettings().catch(() => {});
59
+
60
+ _pollTimer = setInterval(() => {
61
+ fetchRemoteSettings().catch(() => {});
62
+ }, intervalMs);
63
+ }
64
+
65
+ /** Stop polling */
66
+ export function stopPolling(): void {
67
+ if (_pollTimer) {
68
+ clearInterval(_pollTimer);
69
+ _pollTimer = null;
70
+ }
71
+ }
72
+
73
+ /** Fetch remote settings from server */
74
+ async function fetchRemoteSettings(): Promise<void> {
75
+ if (!_remoteUrl || !_apiKey) return;
76
+
77
+ try {
78
+ const controller = new AbortController();
79
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
80
+
81
+ const response = await fetch(_remoteUrl, {
82
+ headers: {
83
+ Authorization: `Bearer ${_apiKey}`,
84
+ Accept: "application/json",
85
+ },
86
+ signal: controller.signal,
87
+ });
88
+
89
+ clearTimeout(timeout);
90
+
91
+ if (!response.ok) return;
92
+
93
+ const data = (await response.json()) as Omit<RemoteSettings, "fetchedAt">;
94
+ const settings: RemoteSettings = {
95
+ ...data,
96
+ fetchedAt: new Date().toISOString(),
97
+ };
98
+
99
+ await applySettings(settings);
100
+ await cacheSettings(settings);
101
+ _currentSettings = settings;
102
+ _onSettingsUpdate?.(settings);
103
+ } catch {
104
+ // Never crash on remote settings failure — this is best-effort
105
+ }
106
+ }
107
+
108
+ /** Apply remote settings to the running instance */
109
+ async function applySettings(settings: RemoteSettings): Promise<void> {
110
+ // Apply feature flags
111
+ if (settings.features) {
112
+ for (const [flag, enabled] of Object.entries(settings.features)) {
113
+ setFeature(flag, enabled);
114
+ }
115
+ }
116
+
117
+ // Apply killswitches (these override local settings — false means killed)
118
+ if (settings.killswitches) {
119
+ if (settings.killswitches.voiceMode === false)
120
+ setFeature("VOICE_MODE", false);
121
+ if (settings.killswitches.kairosMode === false)
122
+ setFeature("KAIROS", false);
123
+ if (settings.killswitches.teamMode === false)
124
+ setFeature("TEAM_MODE", false);
125
+ }
126
+ }
127
+
128
+ /** Cache settings to disk for offline use */
129
+ async function cacheSettings(settings: RemoteSettings): Promise<void> {
130
+ await mkdir(getConfigDir(), { recursive: true });
131
+ await writeFile(
132
+ getCachePath(),
133
+ JSON.stringify(settings, null, 2),
134
+ "utf-8",
135
+ );
136
+ }
137
+
138
+ /** Load cached settings (for offline startup) */
139
+ export async function loadCachedSettings(): Promise<RemoteSettings | null> {
140
+ const cachePath = getCachePath();
141
+ if (!existsSync(cachePath)) return null;
142
+ try {
143
+ const raw = await readFile(cachePath, "utf-8");
144
+ const settings = JSON.parse(raw) as RemoteSettings;
145
+ _currentSettings = settings;
146
+ await applySettings(settings);
147
+ return settings;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** Get current remote settings */
154
+ export function getRemoteSettings(): RemoteSettings | null {
155
+ return _currentSettings;
156
+ }
157
+
158
+ /** Check if a specific killswitch is active (i.e. the feature is killed) */
159
+ export function isKillswitchActive(
160
+ name: keyof NonNullable<RemoteSettings["killswitches"]>,
161
+ ): boolean {
162
+ return _currentSettings?.killswitches?.[name] === false;
163
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Settings Sync — cross-device configuration synchronization.
3
+ *
4
+ * Two modes:
5
+ * 1. Export/Import — manual file-based sync
6
+ * 2. Git-based — auto-sync settings to a git repo
7
+ */
8
+
9
+ import { existsSync } from "fs";
10
+ import { readFile, writeFile, mkdir, readdir, copyFile, stat } from "fs/promises";
11
+ import { join, dirname } from "path";
12
+ import { hostname } from "os";
13
+ import { getConfigDir } from "./settings.ts";
14
+
15
+ interface SyncManifest {
16
+ version: number;
17
+ exportedAt: string;
18
+ hostname: string;
19
+ platform: string;
20
+ files: string[];
21
+ }
22
+
23
+ const SYNCABLE_FILES = [
24
+ "settings.json",
25
+ "permissions.json",
26
+ "keybindings.json",
27
+ "buddy.json",
28
+ ];
29
+
30
+ const SYNCABLE_DIRS = [
31
+ "memory",
32
+ "workflows",
33
+ "triggers",
34
+ "teams",
35
+ ];
36
+
37
+ /**
38
+ * Export settings to a sync bundle directory.
39
+ */
40
+ export async function exportSettings(targetDir: string): Promise<SyncManifest> {
41
+ const configDir = getConfigDir();
42
+ await mkdir(targetDir, { recursive: true });
43
+
44
+ const files: string[] = [];
45
+
46
+ // Copy individual files
47
+ for (const file of SYNCABLE_FILES) {
48
+ const src = join(configDir, file);
49
+ if (existsSync(src)) {
50
+ await copyFile(src, join(targetDir, file));
51
+ files.push(file);
52
+ }
53
+ }
54
+
55
+ // Copy directories
56
+ for (const dir of SYNCABLE_DIRS) {
57
+ const srcDir = join(configDir, dir);
58
+ if (!existsSync(srcDir)) continue;
59
+
60
+ const destDir = join(targetDir, dir);
61
+ await mkdir(destDir, { recursive: true });
62
+
63
+ const dirFiles = await readdir(srcDir);
64
+ for (const f of dirFiles) {
65
+ if (f.startsWith(".")) continue;
66
+ await copyFile(join(srcDir, f), join(destDir, f));
67
+ files.push(`${dir}/${f}`);
68
+ }
69
+ }
70
+
71
+ const manifest: SyncManifest = {
72
+ version: 1,
73
+ exportedAt: new Date().toISOString(),
74
+ hostname: hostname(),
75
+ platform: process.platform,
76
+ files,
77
+ };
78
+
79
+ await writeFile(join(targetDir, "sync-manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
80
+ return manifest;
81
+ }
82
+
83
+ /**
84
+ * Import settings from a sync bundle directory.
85
+ */
86
+ export async function importSettings(
87
+ sourceDir: string,
88
+ options: { overwrite?: boolean; merge?: boolean } = {},
89
+ ): Promise<{ imported: string[]; skipped: string[] }> {
90
+ const configDir = getConfigDir();
91
+ const manifestPath = join(sourceDir, "sync-manifest.json");
92
+
93
+ if (!existsSync(manifestPath)) {
94
+ throw new Error("No sync-manifest.json found in source directory");
95
+ }
96
+
97
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8")) as SyncManifest;
98
+ const imported: string[] = [];
99
+ const skipped: string[] = [];
100
+
101
+ for (const file of manifest.files) {
102
+ const src = join(sourceDir, file);
103
+ const dest = join(configDir, file);
104
+
105
+ if (!existsSync(src)) {
106
+ skipped.push(file);
107
+ continue;
108
+ }
109
+
110
+ // Create parent directory if needed
111
+ await mkdir(dirname(dest), { recursive: true });
112
+
113
+ if (existsSync(dest) && !options.overwrite) {
114
+ if (options.merge && file.endsWith(".json")) {
115
+ // Merge JSON files — incoming values override existing
116
+ try {
117
+ const existing = JSON.parse(await readFile(dest, "utf-8"));
118
+ const incoming = JSON.parse(await readFile(src, "utf-8"));
119
+ const merged = { ...existing, ...incoming };
120
+ await writeFile(dest, JSON.stringify(merged, null, 2), "utf-8");
121
+ imported.push(`${file} (merged)`);
122
+ continue;
123
+ } catch {
124
+ // Fall through to skip if merge fails
125
+ }
126
+ }
127
+ skipped.push(`${file} (exists)`);
128
+ continue;
129
+ }
130
+
131
+ await copyFile(src, dest);
132
+ imported.push(file);
133
+ }
134
+
135
+ return { imported, skipped };
136
+ }
137
+
138
+ /**
139
+ * Get sync status — what would be synced.
140
+ */
141
+ export async function getSyncStatus(): Promise<{ files: string[]; totalSize: number }> {
142
+ const configDir = getConfigDir();
143
+ const files: string[] = [];
144
+ let totalSize = 0;
145
+
146
+ for (const file of SYNCABLE_FILES) {
147
+ const path = join(configDir, file);
148
+ if (existsSync(path)) {
149
+ const s = await stat(path);
150
+ files.push(`${file} (${formatSize(s.size)})`);
151
+ totalSize += s.size;
152
+ }
153
+ }
154
+
155
+ for (const dir of SYNCABLE_DIRS) {
156
+ const dirPath = join(configDir, dir);
157
+ if (!existsSync(dirPath)) continue;
158
+ const dirFiles = await readdir(dirPath);
159
+ const count = dirFiles.filter((f) => !f.startsWith(".")).length;
160
+ if (count > 0) files.push(`${dir}/ (${count} files)`);
161
+ }
162
+
163
+ return { files, totalSize };
164
+ }
165
+
166
+ function formatSize(bytes: number): string {
167
+ if (bytes < 1024) return `${bytes}B`;
168
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
169
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
170
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Settings — configuration management for AshlrCode.
3
+ */
4
+
5
+ import { existsSync } from "fs";
6
+ import { readFile, writeFile, mkdir } from "fs/promises";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+ import type { ProviderRouterConfig } from "../providers/types.ts";
10
+ import type { HooksConfig } from "./hooks.ts";
11
+ import type { MCPServerConfig } from "../mcp/types.ts";
12
+
13
+ export interface ToolHookRule {
14
+ /** Glob pattern for tool name (e.g. "Bash", "File*") */
15
+ tool?: string;
16
+ /** Regex to match against JSON-serialized input */
17
+ inputPattern?: string;
18
+ /** Shell command to run (gets TOOL_NAME, TOOL_INPUT env vars) */
19
+ command?: string;
20
+ /** Direct action without running a command */
21
+ action?: "allow" | "deny";
22
+ }
23
+
24
+ export interface PostToolHookRule {
25
+ /** Glob pattern for tool name */
26
+ tool?: string;
27
+ /** Shell command to run (gets TOOL_NAME, TOOL_INPUT, TOOL_RESULT env vars) */
28
+ command?: string;
29
+ }
30
+
31
+ export interface Settings {
32
+ providers: ProviderRouterConfig;
33
+ defaultModel?: string;
34
+ maxTokens?: number;
35
+ hooks?: HooksConfig;
36
+ toolHooks?: {
37
+ preToolUse?: ToolHookRule[];
38
+ postToolUse?: PostToolHookRule[];
39
+ };
40
+ mcpServers?: Record<string, MCPServerConfig>;
41
+ permissionRules?: Array<{ tool: string; inputPattern?: string; action: "allow" | "deny" | "ask" }>;
42
+ remoteSettingsUrl?: string;
43
+ }
44
+
45
+ let configDirOverride: string | null = null;
46
+
47
+ function getDefaultConfigDir(): string {
48
+ return process.env.ASHLRCODE_CONFIG_DIR ?? join(homedir(), ".ashlrcode");
49
+ }
50
+
51
+ function getSettingsPath(): string {
52
+ return join(getConfigDir(), "settings.json");
53
+ }
54
+
55
+ export async function loadSettings(): Promise<Settings> {
56
+ const defaults = getDefaultSettings();
57
+ const settingsPath = getSettingsPath();
58
+
59
+ if (existsSync(settingsPath)) {
60
+ const raw = await readFile(settingsPath, "utf-8");
61
+ const fileSettings = JSON.parse(raw) as Partial<Settings>;
62
+
63
+ // Merge file settings with defaults — file settings override but
64
+ // providers always come from env vars / defaults if not in file
65
+ return {
66
+ ...defaults,
67
+ ...fileSettings,
68
+ providers: fileSettings.providers ?? defaults.providers,
69
+ hooks: fileSettings.hooks ?? defaults.hooks,
70
+ toolHooks: fileSettings.toolHooks ?? defaults.toolHooks,
71
+ mcpServers: fileSettings.mcpServers ?? defaults.mcpServers,
72
+ };
73
+ }
74
+
75
+ return defaults;
76
+ }
77
+
78
+ export async function saveSettings(settings: Settings): Promise<void> {
79
+ const configDir = getConfigDir();
80
+ await mkdir(configDir, { recursive: true });
81
+ await writeFile(getSettingsPath(), JSON.stringify(settings, null, 2), "utf-8");
82
+ }
83
+
84
+ function getDefaultSettings(): Settings {
85
+ return {
86
+ providers: {
87
+ primary: {
88
+ provider: "xai",
89
+ apiKey: process.env.XAI_API_KEY ?? "",
90
+ model: process.env.AC_MODEL ?? "grok-4-1-fast-reasoning",
91
+ baseURL: "https://api.x.ai/v1",
92
+ },
93
+ fallbacks: process.env.ANTHROPIC_API_KEY
94
+ ? [
95
+ {
96
+ provider: "anthropic",
97
+ apiKey: process.env.ANTHROPIC_API_KEY,
98
+ model: "claude-sonnet-4-6-20250514",
99
+ },
100
+ ]
101
+ : [],
102
+ },
103
+ maxTokens: 8192,
104
+ };
105
+ }
106
+
107
+ export function getConfigDir(): string {
108
+ return configDirOverride ?? getDefaultConfigDir();
109
+ }
110
+
111
+ export function setConfigDirForTests(configDir: string | null): void {
112
+ configDirOverride = configDir;
113
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Undercover Mode — masks AI attribution in output and commits.
3
+ *
4
+ * When active, the agent avoids revealing it's an AI:
5
+ * - No Co-Authored-By lines in commits
6
+ * - No model names or codenames in output
7
+ * - No "as an AI" language
8
+ * - No tool names or internal terminology
9
+ */
10
+
11
+ let _undercoverMode = false;
12
+
13
+ export function isUndercoverMode(): boolean {
14
+ return _undercoverMode;
15
+ }
16
+
17
+ export function setUndercoverMode(enabled: boolean): void {
18
+ _undercoverMode = enabled;
19
+ }
20
+
21
+ /** Model codenames to mask in output */
22
+ const CODENAMES = [
23
+ "capybara", "fennec", "numbat", "grok", "claude",
24
+ "opus", "sonnet", "haiku", "ashlrcode", "ashlr",
25
+ ];
26
+
27
+ /**
28
+ * Mask sensitive codenames in text output.
29
+ * e.g., "capybara-v8" → "cap*****-v8"
30
+ */
31
+ export function maskCodenames(text: string): string {
32
+ if (!_undercoverMode) return text;
33
+
34
+ let result = text;
35
+ for (const name of CODENAMES) {
36
+ const regex = new RegExp(`\\b${name}`, "gi");
37
+ result = result.replace(regex, (match) => {
38
+ if (match.length <= 3) return match;
39
+ return match.slice(0, 3) + "*".repeat(match.length - 3);
40
+ });
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Strip AI attribution from commit messages.
47
+ */
48
+ export function sanitizeCommitMessage(message: string): string {
49
+ if (!_undercoverMode) return message;
50
+
51
+ return message
52
+ .replace(/Co-Authored-By:.*$/gm, "")
53
+ .replace(/\n{3,}/g, "\n\n")
54
+ .trim();
55
+ }
56
+
57
+ /**
58
+ * Get undercover system prompt addition.
59
+ */
60
+ export function getUndercoverPrompt(): string {
61
+ if (!_undercoverMode) return "";
62
+
63
+ return `
64
+
65
+ ## UNDERCOVER MODE ACTIVE
66
+
67
+ You are operating in undercover mode. Follow these rules strictly:
68
+ - Do NOT reveal that you are an AI, language model, or automated tool
69
+ - Do NOT mention model names (Claude, GPT, Grok, etc.)
70
+ - Do NOT add Co-Authored-By lines to commits
71
+ - Do NOT reference internal tooling, codenames, or model versions
72
+ - Write code and messages as if you are a human developer
73
+ - If asked about your identity, deflect naturally
74
+ - Do NOT use phrases like "as an AI", "I'm a language model", etc.
75
+ `;
76
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Upgrade notice — check for new releases on startup.
3
+ */
4
+
5
+ import { readFile, writeFile, mkdir } from "fs/promises";
6
+ import { existsSync } from "fs";
7
+ import { join } from "path";
8
+ import { getConfigDir } from "./settings.ts";
9
+
10
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // Once per day
11
+
12
+ interface UpgradeState {
13
+ lastCheck: string;
14
+ latestVersion?: string;
15
+ currentVersion: string;
16
+ }
17
+
18
+ function getStatePath(): string {
19
+ return join(getConfigDir(), "upgrade-state.json");
20
+ }
21
+
22
+ export async function checkForUpgrade(currentVersion: string): Promise<string | null> {
23
+ const path = getStatePath();
24
+
25
+ // Check if we already checked recently
26
+ if (existsSync(path)) {
27
+ try {
28
+ const raw = await readFile(path, "utf-8");
29
+ const state = JSON.parse(raw) as UpgradeState;
30
+ if (Date.now() - new Date(state.lastCheck).getTime() < CHECK_INTERVAL) {
31
+ // Already checked recently — return cached result
32
+ if (state.latestVersion && state.latestVersion !== currentVersion) {
33
+ return state.latestVersion;
34
+ }
35
+ return null;
36
+ }
37
+ } catch {}
38
+ }
39
+
40
+ try {
41
+ // Check npm registry for latest version
42
+ const controller = new AbortController();
43
+ setTimeout(() => controller.abort(), 5000);
44
+ const response = await fetch("https://registry.npmjs.org/ashlrcode/latest", {
45
+ signal: controller.signal,
46
+ });
47
+
48
+ if (!response.ok) return null;
49
+ const data = await response.json() as { version: string };
50
+
51
+ const state: UpgradeState = {
52
+ lastCheck: new Date().toISOString(),
53
+ latestVersion: data.version,
54
+ currentVersion,
55
+ };
56
+
57
+ await mkdir(getConfigDir(), { recursive: true });
58
+ await writeFile(getStatePath(), JSON.stringify(state), "utf-8");
59
+
60
+ if (data.version !== currentVersion) return data.version;
61
+ return null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }