work-ally 0.2.0-alpha.1

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 (172) hide show
  1. package/AGENTS.md +110 -0
  2. package/DASHBOARD.md +160 -0
  3. package/PRODUCT.md +113 -0
  4. package/README.md +403 -0
  5. package/ally.sh +171 -0
  6. package/bridge/src/approval-rules.ts +360 -0
  7. package/bridge/src/channel-delivery.ts +207 -0
  8. package/bridge/src/channel-types.ts +22 -0
  9. package/bridge/src/channels/fake/adapter.ts +31 -0
  10. package/bridge/src/channels/feishu/adapter.ts +411 -0
  11. package/bridge/src/channels/feishu/approvals.ts +6 -0
  12. package/bridge/src/channels/feishu/formatter.ts +276 -0
  13. package/bridge/src/channels/feishu/normalize.ts +368 -0
  14. package/bridge/src/codex-config.ts +52 -0
  15. package/bridge/src/config.ts +240 -0
  16. package/bridge/src/fake-runtime-client.ts +505 -0
  17. package/bridge/src/handoff-service.ts +494 -0
  18. package/bridge/src/logger.ts +194 -0
  19. package/bridge/src/memory-digest.ts +186 -0
  20. package/bridge/src/receiver-approval-autonomy.ts +158 -0
  21. package/bridge/src/receiver-control-core.ts +140 -0
  22. package/bridge/src/receiver-control-work-session.ts +218 -0
  23. package/bridge/src/receiver-control.ts +83 -0
  24. package/bridge/src/receiver-delivery.ts +136 -0
  25. package/bridge/src/receiver-helpers.ts +96 -0
  26. package/bridge/src/receiver-human-gate.ts +333 -0
  27. package/bridge/src/receiver-inbound-preflight.ts +162 -0
  28. package/bridge/src/receiver-recovery.ts +236 -0
  29. package/bridge/src/receiver-runtime-callbacks.ts +367 -0
  30. package/bridge/src/receiver-runtime-policy.ts +132 -0
  31. package/bridge/src/receiver-runtime-state.ts +124 -0
  32. package/bridge/src/receiver-support-actions.ts +189 -0
  33. package/bridge/src/receiver-thread-start.ts +57 -0
  34. package/bridge/src/receiver-turn-coordination.ts +94 -0
  35. package/bridge/src/receiver-turn-execution.ts +257 -0
  36. package/bridge/src/receiver-turn-failure.ts +143 -0
  37. package/bridge/src/receiver-turn-result.ts +185 -0
  38. package/bridge/src/receiver-turn-steer.ts +70 -0
  39. package/bridge/src/receiver-work-session.ts +76 -0
  40. package/bridge/src/receiver.ts +329 -0
  41. package/bridge/src/router.ts +62 -0
  42. package/bridge/src/runtime-client-agent-messages.ts +150 -0
  43. package/bridge/src/runtime-client-message-dispatch.ts +176 -0
  44. package/bridge/src/runtime-client-protocol.ts +411 -0
  45. package/bridge/src/runtime-client-request-ops.ts +56 -0
  46. package/bridge/src/runtime-client-run-turn.ts +158 -0
  47. package/bridge/src/runtime-client-thread-ops.ts +270 -0
  48. package/bridge/src/runtime-client-transport.ts +309 -0
  49. package/bridge/src/runtime-client-turn-poll.ts +224 -0
  50. package/bridge/src/runtime-client-turn-read.ts +185 -0
  51. package/bridge/src/runtime-client-turn-state.ts +105 -0
  52. package/bridge/src/runtime-client.ts +344 -0
  53. package/bridge/src/runtime-user-input.ts +403 -0
  54. package/bridge/src/scheduler.ts +239 -0
  55. package/bridge/src/server-handoff-command.ts +364 -0
  56. package/bridge/src/server-main.ts +80 -0
  57. package/bridge/src/server-routine-command.ts +60 -0
  58. package/bridge/src/server-routine-execution.ts +222 -0
  59. package/bridge/src/server-runtime-app-support.ts +107 -0
  60. package/bridge/src/server-runtime-app.ts +238 -0
  61. package/bridge/src/server-thread-sync-command.ts +63 -0
  62. package/bridge/src/server.ts +17 -0
  63. package/bridge/src/session-store-delivery.ts +220 -0
  64. package/bridge/src/session-store-human-gate.ts +380 -0
  65. package/bridge/src/session-store-inbound-acceptance.ts +66 -0
  66. package/bridge/src/session-store-meta.ts +134 -0
  67. package/bridge/src/session-store-turn-ledger.ts +272 -0
  68. package/bridge/src/session-store.ts +380 -0
  69. package/bridge/src/system-notify.ts +220 -0
  70. package/bridge/src/thread-sync.ts +200 -0
  71. package/bridge/src/translator.ts +494 -0
  72. package/bridge/src/types.ts +289 -0
  73. package/bridge/src/utils.ts +104 -0
  74. package/bridge/src/work-session-store.ts +471 -0
  75. package/docs/.gitkeep +0 -0
  76. package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
  77. package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
  78. package/docs/completed/README.md +21 -0
  79. package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
  80. package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
  81. package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
  82. package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
  83. package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
  84. package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
  85. package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
  86. package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
  87. package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
  88. package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
  89. package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
  90. package/docs/developer-workflow.md +49 -0
  91. package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
  92. package/docs/implementation/test-coverage-map.md +363 -0
  93. package/docs/implementation/work-ally-implementation-guide.md +790 -0
  94. package/docs/issues/README.md +10 -0
  95. package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
  96. package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
  97. package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
  98. package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
  99. package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
  100. package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
  101. package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
  102. package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
  103. package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
  104. package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
  105. package/docs/manual-acceptance.md +127 -0
  106. package/docs/ops-runbook.md +44 -0
  107. package/docs/planning/FEATURE-memory-system.md +748 -0
  108. package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
  109. package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
  110. package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
  111. package/docs/planning/SPEC-assistant-rename.md +610 -0
  112. package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
  113. package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
  114. package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
  115. package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
  116. package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
  117. package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
  118. package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
  119. package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
  120. package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
  121. package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
  122. package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
  123. package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
  124. package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
  125. package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
  126. package/docs/planning/SPEC-stable-archive-contract.md +456 -0
  127. package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
  128. package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
  129. package/docs/planning/ally-next.md +1278 -0
  130. package/docs/planning/assistant-workbench-spec.md +725 -0
  131. package/docs/planning/product-workbench.md +283 -0
  132. package/docs/product-onboarding.md +227 -0
  133. package/docs/product-spec-standard.md +528 -0
  134. package/docs/troubleshooting.md +45 -0
  135. package/docs/user-quickstart.md +46 -0
  136. package/internal/dispatch.sh +95 -0
  137. package/internal/lib/common.sh +1450 -0
  138. package/internal/modules/assistant/manage.sh +1312 -0
  139. package/internal/modules/bootstrap/setup.sh +144 -0
  140. package/internal/modules/config/init-env.sh +10 -0
  141. package/internal/modules/global/manage.sh +154 -0
  142. package/internal/modules/handoff/manage.sh +54 -0
  143. package/internal/modules/mcp/manage.sh +83 -0
  144. package/internal/modules/ops/logs.sh +76 -0
  145. package/internal/modules/routines/manage.sh +55 -0
  146. package/internal/modules/runtime/assistant-autosave.sh +26 -0
  147. package/internal/modules/runtime/restart.sh +6 -0
  148. package/internal/modules/runtime/start.sh +283 -0
  149. package/internal/modules/runtime/status.sh +194 -0
  150. package/internal/modules/runtime/stop.sh +55 -0
  151. package/internal/modules/runtime/supervisor.sh +216 -0
  152. package/internal/modules/runtime/update.sh +26 -0
  153. package/package.json +41 -0
  154. package/runtime/config/.gitkeep +0 -0
  155. package/runtime/host/.gitkeep +0 -0
  156. package/runtime/host/healthcheck-codex-app-server.ts +22 -0
  157. package/runtime/host/ping-pong-codex-app-server.ts +66 -0
  158. package/runtime/host/probe-codex-app-server.ts +115 -0
  159. package/skills/archive-reader/SKILL.md +9 -0
  160. package/skills/feishu-production-debug/SKILL.md +37 -0
  161. package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
  162. package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
  163. package/skills/issue-to-spec-triage/SKILL.md +44 -0
  164. package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
  165. package/skills/memory-digest/SKILL.md +9 -0
  166. package/skills/post-implementation-closure/SKILL.md +39 -0
  167. package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
  168. package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
  169. package/skills/product-spec/SKILL.md +244 -0
  170. package/templates/env.example +5 -0
  171. package/templates/routines/nightly-memory-digest.yaml +10 -0
  172. package/templates/workspace/AGENTS.md +26 -0
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { WorkAllyConfig } from './config.ts';
4
+ import { todayStamp } from './utils.ts';
5
+
6
+ const MAX_ARCHIVE_BYTES = Number(process.env.WORK_ALLY_MEMORY_DIGEST_MAX_ARCHIVE_BYTES || '120000');
7
+ const DAILY_START = '<<<DAILY_MEMORY>>>';
8
+ const DAILY_END = '<<<END_DAILY_MEMORY>>>';
9
+ const LONG_START = '<<<LONG_TERM_MEMORY>>>';
10
+ const LONG_END = '<<<END_LONG_TERM_MEMORY>>>';
11
+ const SUMMARY_START = '<<<SUMMARY>>>';
12
+ const SUMMARY_END = '<<<END_SUMMARY>>>';
13
+
14
+ function readTextFile(filePath: string): string {
15
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
16
+ return '';
17
+ }
18
+ return fs.readFileSync(filePath, 'utf8').trim();
19
+ }
20
+
21
+ function listFilesRecursive(root: string): string[] {
22
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
23
+ return [];
24
+ }
25
+
26
+ const files: string[] = [];
27
+ const walk = (current: string) => {
28
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
29
+ const full = path.join(current, entry.name);
30
+ if (entry.isDirectory()) {
31
+ walk(full);
32
+ continue;
33
+ }
34
+ files.push(full);
35
+ }
36
+ };
37
+ walk(root);
38
+ return files.sort();
39
+ }
40
+
41
+ function archiveDayDir(archiveRoot: string, dateStamp: string): string {
42
+ const [year, month, day] = dateStamp.split('-');
43
+ return path.join(archiveRoot, year, month, day);
44
+ }
45
+
46
+ function collectArchiveSections(config: WorkAllyConfig, dateStamp: string, excludedRoutineIds: string[] = []): { text: string; truncated: boolean; fileCount: number } {
47
+ const dayDir = archiveDayDir(config.paths.archiveDir, dateStamp);
48
+ const excludedNames = new Set(excludedRoutineIds.map((id) => `routine--${id}.ndjson`));
49
+ const files = listFilesRecursive(dayDir).filter((file) => {
50
+ const base = path.basename(file);
51
+ if (!base.endsWith('.ndjson')) return false;
52
+ if (excludedNames.has(base)) return false;
53
+ // Thread sync files are keyed by thread id; legacy channel files contain "--".
54
+ return !base.includes('--');
55
+ });
56
+ if (files.length === 0) {
57
+ return {
58
+ text: '(当天没有归档原材料)',
59
+ truncated: false,
60
+ fileCount: 0,
61
+ };
62
+ }
63
+
64
+ let remaining = Math.max(4096, MAX_ARCHIVE_BYTES);
65
+ let truncated = false;
66
+ const parts: string[] = [];
67
+
68
+ for (const file of files) {
69
+ if (remaining <= 0) {
70
+ truncated = true;
71
+ break;
72
+ }
73
+
74
+ const relativePath = path.relative(config.workspaceRoot, file) || path.basename(file);
75
+ const content = fs.readFileSync(file, 'utf8');
76
+ const header = `## FILE: ${relativePath}\n`;
77
+ const bodyBudget = Math.max(0, remaining - Buffer.byteLength(header, 'utf8') - 2);
78
+ if (bodyBudget <= 0) {
79
+ truncated = true;
80
+ break;
81
+ }
82
+
83
+ let body = content;
84
+ const bodyBytes = Buffer.byteLength(body, 'utf8');
85
+ if (bodyBytes > bodyBudget) {
86
+ body = Buffer.from(body).subarray(0, bodyBudget).toString('utf8');
87
+ body += '\n\n[TRUNCATED]\n';
88
+ truncated = true;
89
+ }
90
+
91
+ const section = `${header}${body}`.trimEnd();
92
+ parts.push(section);
93
+ remaining -= Buffer.byteLength(section, 'utf8') + 2;
94
+ }
95
+
96
+ if (truncated) {
97
+ parts.push('## NOTE\n归档原材料过多,已按顺序截断;请基于当前已提供内容提炼记忆。');
98
+ }
99
+
100
+ return {
101
+ text: parts.join('\n\n'),
102
+ truncated,
103
+ fileCount: files.length,
104
+ };
105
+ }
106
+
107
+ function currentMemorySnapshot(config: WorkAllyConfig, dateStamp: string): string {
108
+ const dailyFile = path.join(config.paths.dailyMemoryDir, `${dateStamp}.md`);
109
+ const longTermFile = path.join(config.paths.longTermMemoryDir, 'MEMORY.md');
110
+ const daily = readTextFile(dailyFile);
111
+ const longTerm = readTextFile(longTermFile);
112
+
113
+ return [
114
+ '## CURRENT DAILY MEMORY',
115
+ daily || '(当前为空)',
116
+ '',
117
+ '## CURRENT LONG-TERM MEMORY',
118
+ longTerm || '(当前为空)',
119
+ ].join('\n');
120
+ }
121
+
122
+ export function buildMemoryDigestPrompt(config: WorkAllyConfig, dateStamp = todayStamp(), routineId = 'nightly-memory-digest'): string {
123
+ const archive = collectArchiveSections(config, dateStamp, [routineId]);
124
+ const memorySnapshot = currentMemorySnapshot(config, dateStamp);
125
+
126
+ return [
127
+ `你正在执行 work-ally 的 nightly memory digest,处理日期是 ${dateStamp}。`,
128
+ '禁止调用任何工具、命令或文件读取操作。下面已经提供你需要的全部原材料与当前记忆快照。',
129
+ '你的任务是只基于这些材料,输出三段内容:',
130
+ '- 摘要:适合通过系统消息告知用户这次记忆整理做了什么',
131
+ '- 当日日记:适合写入 journal/YYYY-MM-DD.md',
132
+ '- 长期记忆:适合覆盖 MEMORY.md',
133
+ '请严格按以下格式输出,不能有任何额外说明、前言或后记:',
134
+ `${SUMMARY_START}
135
+ - 新增了什么
136
+ - 更新了什么
137
+ - 如果没有实质变化请明确写“无新增稳定记忆”
138
+ ${SUMMARY_END}
139
+ ${DAILY_START}
140
+ # Journal - ${dateStamp}
141
+ ...
142
+ ${DAILY_END}
143
+ ${LONG_START}
144
+ # MEMORY
145
+ ...
146
+ ${LONG_END}`,
147
+ 'ARCHIVE MATERIALS 是目标日期的唯一事实来源;CURRENT MEMORY 只是参考快照,可能包含过时、低价值或上次失败产物。',
148
+ '摘要只写本次记忆整理的结果,不要复述全部对话,不要写实现过程。',
149
+ '重写每日记忆时,优先忠实反映当天 archive,不要沿袭旧摘要里的措辞或结论。',
150
+ '重写长期记忆时,只保留持久事实:用户偏好、工作空间规则、稳定流程、持续有效的操作约束、长期项目背景。',
151
+ '长期记忆不要重复通用的 work-ally 产品机制、默认目录结构、实现细节或例行任务说明,除非 archive 明确显示它们已经成为这个工作空间的稳定规则。',
152
+ '不要把以下内容继续写入长期记忆:一次性的寒暄、单次失败、调试噪音、thread/turn id、原始报错、例行任务运行元数据。',
153
+ '如果当天几乎没有实质活动,要明确写成“低活动日”,但不要臆造新事实。',
154
+ '如果当前没有可保留的长期事实,请输出一个极简的长期记忆,明确写出“暂无新增稳定事实”,不要为了凑内容重复系统常识。',
155
+ `原材料目录:${config.paths.archiveDir}`,
156
+ `日记输出目录:${config.paths.dailyMemoryDir}`,
157
+ `长期记忆输出目录:${config.paths.longTermMemoryDir}`,
158
+ `归档文件数量:${archive.fileCount}`,
159
+ memorySnapshot,
160
+ '## ARCHIVE MATERIALS',
161
+ archive.text,
162
+ ].join('\n\n');
163
+ }
164
+
165
+
166
+ function between(text: string, start: string, end: string): string | null {
167
+ const startIndex = text.indexOf(start);
168
+ if (startIndex < 0) {
169
+ return null;
170
+ }
171
+ const endIndex = text.indexOf(end, startIndex + start.length);
172
+ if (endIndex < 0) {
173
+ return null;
174
+ }
175
+ return text.slice(startIndex + start.length, endIndex).trim();
176
+ }
177
+
178
+ export function parseMemoryDigestReply(reply: string): { summary: string | null; daily: string; longTerm: string } | null {
179
+ const summary = between(reply, SUMMARY_START, SUMMARY_END);
180
+ const daily = between(reply, DAILY_START, DAILY_END);
181
+ const longTerm = between(reply, LONG_START, LONG_END);
182
+ if (!daily || !longTerm) {
183
+ return null;
184
+ }
185
+ return { summary, daily, longTerm };
186
+ }
@@ -0,0 +1,158 @@
1
+ import path from 'node:path';
2
+ import { commandApprovalRuleLabel, findMatchingCommandApprovalRule, type CommandApprovalRuleset, unwrapShellApprovalCommand } from './approval-rules.ts';
3
+ import { HIGH_RISK_APPROVAL_COMMAND_RE, LOCAL_APPROVAL_COMMAND_RE } from './receiver-helpers.ts';
4
+ import type { ApprovalRecord } from './types.ts';
5
+
6
+ interface ApprovalAutonomyDeps {
7
+ workspaceRoot: string;
8
+ assistantHome?: string | null;
9
+ commandApprovalRules?: CommandApprovalRuleset | null;
10
+ }
11
+
12
+ export interface ApprovalAutonomyDecision {
13
+ action: 'accept' | 'decline';
14
+ reason: string;
15
+ policy: 'local_workspace_edit' | 'local_self_validation' | 'rules_allow' | 'rules_forbidden';
16
+ matchedRule?: string;
17
+ ruleSource?: 'global' | 'assistant';
18
+ commandForm?: 'raw' | 'unwrapped';
19
+ }
20
+
21
+ function autonomyRoots(deps: ApprovalAutonomyDeps): string[] {
22
+ const roots = [path.resolve(deps.workspaceRoot)];
23
+ const assistantHome = String(deps.assistantHome || '').trim();
24
+ if (assistantHome) {
25
+ roots.push(path.resolve(assistantHome));
26
+ }
27
+ return Array.from(new Set(roots));
28
+ }
29
+
30
+ function isWithinAutonomyRoots(deps: ApprovalAutonomyDeps, target: string | null | undefined): boolean {
31
+ const normalized = String(target || '').trim();
32
+ if (!normalized) {
33
+ return false;
34
+ }
35
+ const resolved = path.resolve(normalized);
36
+ return autonomyRoots(deps).some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`));
37
+ }
38
+
39
+ function splitApprovalCommandSegments(command: string): string[] | null {
40
+ const unwrapped = unwrapShellApprovalCommand(command);
41
+ if (!unwrapped) {
42
+ return null;
43
+ }
44
+ if (/[|;><`]/.test(unwrapped) || /\|\|/.test(unwrapped)) {
45
+ return null;
46
+ }
47
+ return unwrapped
48
+ .split(/\s*&&\s*/)
49
+ .map((segment) => segment.trim())
50
+ .filter(Boolean);
51
+ }
52
+
53
+ function isLocalSelfValidationCommand(command: string): boolean {
54
+ const segments = splitApprovalCommandSegments(command);
55
+ if (!segments?.length) {
56
+ return false;
57
+ }
58
+ return segments.every((segment) => LOCAL_APPROVAL_COMMAND_RE.test(segment));
59
+ }
60
+
61
+ function matchRuleDecision(
62
+ deps: ApprovalAutonomyDeps,
63
+ command: string,
64
+ ): ApprovalAutonomyDecision | null {
65
+ const ruleset = deps.commandApprovalRules;
66
+ if (!ruleset) {
67
+ return null;
68
+ }
69
+ const match = findMatchingCommandApprovalRule(ruleset, command);
70
+ if (!match) {
71
+ return null;
72
+ }
73
+ if (match.rule.decision === 'prompt') {
74
+ return null;
75
+ }
76
+ return {
77
+ action: match.rule.decision === 'forbidden' ? 'decline' : 'accept',
78
+ reason: match.rule.decision === 'forbidden'
79
+ ? '命中已记住的拒绝规则,本次命令将直接拒绝,不再向用户重复发审批卡。'
80
+ : '命中已记住的放行规则,本次命令将直接放行,不再向用户重复发审批卡。',
81
+ policy: match.rule.decision === 'forbidden' ? 'rules_forbidden' : 'rules_allow',
82
+ matchedRule: commandApprovalRuleLabel(match),
83
+ ruleSource: match.rule.source,
84
+ commandForm: match.commandForm,
85
+ };
86
+ }
87
+
88
+ export function approvalBoundaryReason(
89
+ deps: ApprovalAutonomyDeps,
90
+ approval: Pick<ApprovalRecord, 'kind' | 'grantRoot' | 'cwd' | 'command'>,
91
+ ): string {
92
+ if (approval.kind === 'file_change') {
93
+ if (approval.grantRoot && !isWithinAutonomyRoots(deps, approval.grantRoot)) {
94
+ return '这一步会写入当前项目或 assistant 办公桌之外的路径,需要你明确拍板。';
95
+ }
96
+ return '这一步涉及文件写入,但不属于默认放行的本地低风险范围,需要你明确拍板。';
97
+ }
98
+
99
+ const command = unwrapShellApprovalCommand(String(approval.command || ''));
100
+ if (HIGH_RISK_APPROVAL_COMMAND_RE.test(command)) {
101
+ return '这一步会对远端、真实环境或现有数据产生更高风险影响,需要你明确拍板。';
102
+ }
103
+ if (!isWithinAutonomyRoots(deps, approval.cwd || null)) {
104
+ return '这一步不在当前项目或 assistant 办公桌的默认自决范围内,需要你明确拍板。';
105
+ }
106
+ return '这一步超出了我可默认自决的本地验证范围,需要你明确拍板。';
107
+ }
108
+
109
+ export function evaluateApprovalAutonomy(
110
+ deps: ApprovalAutonomyDeps,
111
+ approval: ApprovalRecord,
112
+ ): ApprovalAutonomyDecision | null {
113
+ if (approval.kind === 'file_change') {
114
+ if (!approval.grantRoot || !isWithinAutonomyRoots(deps, approval.grantRoot)) {
115
+ return null;
116
+ }
117
+ return {
118
+ action: 'accept',
119
+ reason: '当前项目或 assistant 办公桌内的本地改动,默认不再逐条打断用户审批。',
120
+ policy: 'local_workspace_edit',
121
+ };
122
+ }
123
+
124
+ const cwd = String(approval.cwd || '').trim();
125
+ const command = String(approval.command || '').trim();
126
+ if (!command) {
127
+ return null;
128
+ }
129
+
130
+ const unwrapped = unwrapShellApprovalCommand(command);
131
+ if (HIGH_RISK_APPROVAL_COMMAND_RE.test(unwrapped)) {
132
+ return null;
133
+ }
134
+
135
+ if (cwd && isWithinAutonomyRoots(deps, cwd) && isLocalSelfValidationCommand(command)) {
136
+ return {
137
+ action: 'accept',
138
+ reason: '当前项目内的低风险本地验证或提交动作,不再默认逐条打断用户审批。',
139
+ policy: 'local_self_validation',
140
+ };
141
+ }
142
+
143
+ return matchRuleDecision(deps, command);
144
+ }
145
+
146
+ export function shouldAutoApproveLocalAction(
147
+ deps: ApprovalAutonomyDeps,
148
+ approval: ApprovalRecord,
149
+ ): { reason: string; policy: string } | null {
150
+ const decision = evaluateApprovalAutonomy(deps, approval);
151
+ if (!decision || decision.action !== 'accept') {
152
+ return null;
153
+ }
154
+ return {
155
+ reason: decision.reason,
156
+ policy: decision.policy,
157
+ };
158
+ }
@@ -0,0 +1,140 @@
1
+ import { loadCodexConfigPreview } from './codex-config.ts';
2
+ import { formatMcpToolApprovalRules, type WorkAllyConfig } from './config.ts';
3
+ import { logInfo, logWarn } from './logger.ts';
4
+ import type { OutboundEnvelope } from './channel-types.ts';
5
+ import type { ControlCommand } from './router.ts';
6
+ import type { SessionStore } from './session-store.ts';
7
+ import { statusMessage } from './translator.ts';
8
+ import type { InboundMessage, RuntimeThreadStatus, SessionMeta } from './types.ts';
9
+
10
+ interface ControlRuntimeLike {
11
+ readThreadStatus(threadId: string): Promise<RuntimeThreadStatus | null>;
12
+ resolveApproval(requestId: string | number, decision: 'accept' | 'acceptForSession' | 'decline' | 'cancel'): Promise<void>;
13
+ interruptTurn(threadId: string, turnId: string): Promise<void>;
14
+ }
15
+
16
+ interface SendLike {
17
+ (
18
+ outbound: OutboundEnvelope,
19
+ extra?: Record<string, unknown>,
20
+ delivery?: { trackTurnId?: string | null },
21
+ ): Promise<boolean>;
22
+ }
23
+
24
+ interface HandleCoreControlDeps {
25
+ config: WorkAllyConfig;
26
+ runtime: ControlRuntimeLike;
27
+ sessionStore: SessionStore;
28
+ send: SendLike;
29
+ commandApprovalRulesStatusLines: string[];
30
+ noteUserRequestedStop(turnId: string): void;
31
+ }
32
+
33
+ interface HandleCoreControlArgs {
34
+ message: InboundMessage;
35
+ command: NonNullable<ControlCommand>;
36
+ session: SessionMeta;
37
+ }
38
+
39
+ export async function handleCoreControlCommand(
40
+ deps: HandleCoreControlDeps,
41
+ args: HandleCoreControlArgs,
42
+ ): Promise<boolean> {
43
+ const { message, command, session } = args;
44
+
45
+ if (command.type === 'status') {
46
+ logInfo('receiver', 'control_status', {
47
+ conversationId: message.conversationRef.conversationId,
48
+ });
49
+ let runtimeStatus: RuntimeThreadStatus | null = null;
50
+ if (session.threadId) {
51
+ try {
52
+ runtimeStatus = await deps.runtime.readThreadStatus(session.threadId);
53
+ } catch (error) {
54
+ logWarn('receiver', 'control_status_runtime_read_failed', {
55
+ conversationId: message.conversationRef.conversationId,
56
+ threadId: session.threadId,
57
+ error: error instanceof Error ? (error.stack || error.message) : String(error),
58
+ });
59
+ }
60
+ }
61
+ await deps.send({
62
+ type: 'status_reply',
63
+ conversationRef: message.conversationRef,
64
+ text: [
65
+ statusMessage(session, runtimeStatus, deps.config.assistant),
66
+ '',
67
+ '**Runtime**',
68
+ `- runtime approval policy: ${deps.config.runtime.approvalPolicy}`,
69
+ `- runtime sandbox mode: ${deps.config.runtime.sandboxMode}`,
70
+ `- runtime mcp tool approvals: ${formatMcpToolApprovalRules(deps.config.runtime.mcpToolApprovalRules)}`,
71
+ '',
72
+ '**Approval Rules**',
73
+ ...deps.commandApprovalRulesStatusLines,
74
+ ].join('\n'),
75
+ });
76
+ return true;
77
+ }
78
+
79
+ if (command.type === 'codex_configure') {
80
+ await deps.send({
81
+ type: 'status_reply',
82
+ conversationRef: message.conversationRef,
83
+ text: loadCodexConfigPreview(),
84
+ });
85
+ return true;
86
+ }
87
+
88
+ if (command.type === 'approve' || command.type === 'deny') {
89
+ logInfo('receiver', 'control_approval_resolution_requested', {
90
+ approvalId: command.approvalId,
91
+ decision: command.type,
92
+ });
93
+ const match = deps.sessionStore.findApproval(command.approvalId);
94
+ if (!match) {
95
+ await deps.send({
96
+ type: 'error_reply',
97
+ conversationRef: message.conversationRef,
98
+ text: `找不到审批:${command.approvalId}`,
99
+ });
100
+ return true;
101
+ }
102
+
103
+ const decision = command.type === 'approve' ? 'accept' : 'decline';
104
+ const resolvedStatus = command.type === 'approve' ? 'accepted' : 'declined';
105
+ await deps.runtime.resolveApproval(match.approval.requestId, decision);
106
+ deps.sessionStore.resolveApproval(match.meta.conversationRef, command.approvalId, resolvedStatus);
107
+ await deps.send({
108
+ type: 'status_reply',
109
+ conversationRef: message.conversationRef,
110
+ text: command.type === 'approve' ? '已提交同意。' : '已提交拒绝。',
111
+ });
112
+ return true;
113
+ }
114
+
115
+ if (command.type === 'stop') {
116
+ logInfo('receiver', 'control_stop_requested', {
117
+ conversationId: message.conversationRef.conversationId,
118
+ threadId: session.threadId,
119
+ activeTurnId: session.activeTurnId,
120
+ });
121
+ if (!session.threadId || !session.activeTurnId) {
122
+ await deps.send({
123
+ type: 'status_reply',
124
+ conversationRef: message.conversationRef,
125
+ text: '当前没有可停止的进行中 turn。',
126
+ });
127
+ return true;
128
+ }
129
+ deps.noteUserRequestedStop(session.activeTurnId);
130
+ await deps.runtime.interruptTurn(session.threadId, session.activeTurnId);
131
+ await deps.send({
132
+ type: 'status_reply',
133
+ conversationRef: message.conversationRef,
134
+ text: '已发送停止请求。',
135
+ });
136
+ return true;
137
+ }
138
+
139
+ return false;
140
+ }
@@ -0,0 +1,218 @@
1
+ import type { WorkAllyConfig } from './config.ts';
2
+ import type { OutboundEnvelope } from './channel-types.ts';
3
+ import type { HandoffService } from './handoff-service.ts';
4
+ import type { ControlCommand } from './router.ts';
5
+ import type { SessionStore } from './session-store.ts';
6
+ import { WorkSessionStore } from './work-session-store.ts';
7
+ import { syncThreadConversationById } from './thread-sync.ts';
8
+ import {
9
+ ambiguousTakeoverMessage,
10
+ busyNewThreadControlMessage,
11
+ codexBindingRequiredMessage,
12
+ codexHandoffMessage,
13
+ newWorkSessionCreatedMessage,
14
+ noLinkedOrAttachedWorkSessionMessage,
15
+ takeoverHandleNotFoundMessage,
16
+ unsupportedHandoffCommandInGroupMessage,
17
+ workSessionAlreadyOnChannelMessage,
18
+ workSessionOwnershipRejectedMessage,
19
+ workSessionTakenOverMessage,
20
+ workSessionThreadsMessage,
21
+ } from './translator.ts';
22
+ import type { ConversationRef, InboundMessage, SessionMeta, WorkSessionMeta } from './types.ts';
23
+
24
+ interface ControlRuntimeLike {
25
+ startThread(cwd: string): Promise<{ id: string }>;
26
+ resumeThread(threadId: string, cwd: string): Promise<{ id: string }>;
27
+ readThreadWithTurns?(threadId: string): Promise<{ id: string; status: { type: string; activeFlags?: string[] } | null; turns: any[] }>;
28
+ }
29
+
30
+ interface SendLike {
31
+ (
32
+ outbound: OutboundEnvelope,
33
+ extra?: Record<string, unknown>,
34
+ delivery?: { trackTurnId?: string | null },
35
+ ): Promise<boolean>;
36
+ }
37
+
38
+ interface HandleWorkSessionControlDeps {
39
+ config: WorkAllyConfig;
40
+ runtime: ControlRuntimeLike;
41
+ sessionStore: SessionStore;
42
+ workSessionStore: WorkSessionStore;
43
+ handoffService: HandoffService;
44
+ send: SendLike;
45
+ assistantName: string;
46
+ assistantCodexHome: string | null;
47
+ resolveDeliveryConversationKey(ref: ConversationRef): string;
48
+ syncLinkedWorkSessionThread(ref: ConversationRef, runtimeThreadId: string | null | undefined): WorkSessionMeta | null;
49
+ }
50
+
51
+ function managedTakeoverCandidates(store: WorkSessionStore, assistantName: string): WorkSessionMeta[] {
52
+ return store.listWorkSessionsForAssistant(assistantName)
53
+ .filter((item) => item.activeSurface !== 'closed');
54
+ }
55
+
56
+ async function sendStatus(
57
+ send: SendLike,
58
+ message: InboundMessage,
59
+ text: string,
60
+ ): Promise<void> {
61
+ await send({
62
+ type: 'status_reply',
63
+ conversationRef: message.conversationRef,
64
+ text,
65
+ });
66
+ }
67
+
68
+ function resolveTakeoverTarget(
69
+ deps: HandleWorkSessionControlDeps,
70
+ args: HandleWorkSessionControlArgs,
71
+ ): { target: WorkSessionMeta | null; failureText?: string } {
72
+ const { message, command, linkedWorkSession } = args;
73
+ const candidates = managedTakeoverCandidates(deps.workSessionStore, deps.assistantName);
74
+
75
+ if (command.type !== 'takeover') {
76
+ return { target: null };
77
+ }
78
+
79
+ if (command.threadHandle) {
80
+ const byHandle = deps.workSessionStore.findWorkSessionByThreadHandle(deps.assistantName, command.threadHandle);
81
+ if (!byHandle || byHandle.archivedAt || byHandle.activeSurface === 'closed') {
82
+ return { target: null, failureText: takeoverHandleNotFoundMessage(command.threadHandle) };
83
+ }
84
+ return { target: byHandle };
85
+ }
86
+
87
+ if (!linkedWorkSession) {
88
+ if (candidates.length === 0) {
89
+ return { target: null, failureText: noLinkedOrAttachedWorkSessionMessage() };
90
+ }
91
+ if (candidates.length > 1) {
92
+ return { target: null, failureText: ambiguousTakeoverMessage(candidates) };
93
+ }
94
+ return { target: candidates[0] };
95
+ }
96
+
97
+ return { target: linkedWorkSession };
98
+ }
99
+
100
+ interface HandleWorkSessionControlArgs {
101
+ message: InboundMessage;
102
+ command: NonNullable<ControlCommand>;
103
+ session: SessionMeta;
104
+ linkedWorkSession: WorkSessionMeta | null;
105
+ }
106
+
107
+ export async function handleWorkSessionControlCommand(
108
+ deps: HandleWorkSessionControlDeps,
109
+ args: HandleWorkSessionControlArgs,
110
+ ): Promise<boolean> {
111
+ const { message, command, session, linkedWorkSession } = args;
112
+
113
+ if (command.type === 'new') {
114
+ if (session.threadId && session.activeTurnId) {
115
+ await deps.send({
116
+ type: 'status_reply',
117
+ conversationRef: message.conversationRef,
118
+ text: busyNewThreadControlMessage(),
119
+ });
120
+ return true;
121
+ }
122
+ if (session.threadId && typeof deps.runtime.readThreadWithTurns === 'function') {
123
+ await syncThreadConversationById(deps.config, deps.runtime, session.threadId);
124
+ }
125
+ const thread = await deps.runtime.startThread(deps.config.workspaceRoot);
126
+ deps.sessionStore.bindThread(message.conversationRef, thread.id);
127
+ deps.workSessionStore.createWorkSession({
128
+ assistantName: deps.assistantName,
129
+ assistantCodexHome: deps.assistantCodexHome,
130
+ workspaceRoot: deps.config.workspaceRoot,
131
+ runtimeThreadId: thread.id,
132
+ cliResumeRef: thread.id,
133
+ cliResumeRefType: 'session_id',
134
+ origin: 'work_ally',
135
+ deliveryChannel: message.conversationRef.channel,
136
+ deliveryConversationKey: deps.resolveDeliveryConversationKey(message.conversationRef),
137
+ activeSurface: 'work_ally_channel',
138
+ ownershipSource: 'new_session',
139
+ });
140
+ await deps.send({
141
+ type: 'status_reply',
142
+ conversationRef: message.conversationRef,
143
+ text: newWorkSessionCreatedMessage(),
144
+ });
145
+ return true;
146
+ }
147
+
148
+ if (command.type === 'takeover') {
149
+ if (message.conversationRef.chatType === 'group') {
150
+ await sendStatus(deps.send, message, unsupportedHandoffCommandInGroupMessage('/takeover'));
151
+ return true;
152
+ }
153
+
154
+ const resolved = resolveTakeoverTarget(deps, args);
155
+ if (!resolved.target) {
156
+ await sendStatus(deps.send, message, resolved.failureText || noLinkedOrAttachedWorkSessionMessage());
157
+ return true;
158
+ }
159
+
160
+ if (!command.threadHandle && linkedWorkSession?.workSessionId === resolved.target.workSessionId && linkedWorkSession.activeSurface === 'work_ally_channel') {
161
+ await sendStatus(deps.send, message, workSessionAlreadyOnChannelMessage());
162
+ return true;
163
+ }
164
+ if (resolved.target.activeSurface === 'closed') {
165
+ await sendStatus(deps.send, message, workSessionOwnershipRejectedMessage('closed'));
166
+ return true;
167
+ }
168
+
169
+ const switchedAt = new Date().toISOString();
170
+ const thread = await deps.runtime.resumeThread(resolved.target.runtimeThreadId, deps.config.workspaceRoot);
171
+ deps.sessionStore.bindThread(message.conversationRef, thread.id);
172
+ const rebound = thread.id !== resolved.target.runtimeThreadId
173
+ ? deps.workSessionStore.rebindRuntimeThread(resolved.target.workSessionId, thread.id)
174
+ : resolved.target;
175
+ const reboundWithDelivery = deps.workSessionStore.bindDeliveryWorkSession(
176
+ rebound.workSessionId,
177
+ message.conversationRef.channel,
178
+ deps.resolveDeliveryConversationKey(message.conversationRef),
179
+ switchedAt,
180
+ );
181
+ deps.workSessionStore.setAssistantActiveWorkSession(deps.assistantName, reboundWithDelivery.workSessionId, switchedAt);
182
+ deps.workSessionStore.updateWorkSession(reboundWithDelivery.workSessionId, {
183
+ activeSurface: 'work_ally_channel',
184
+ ownershipSource: 'explicit_channel_takeover',
185
+ lastSurfaceSwitchAt: switchedAt,
186
+ });
187
+ await sendStatus(deps.send, message, workSessionTakenOverMessage());
188
+ return true;
189
+ }
190
+
191
+ if (command.type === 'threads') {
192
+ if (message.conversationRef.chatType === 'group') {
193
+ await sendStatus(deps.send, message, unsupportedHandoffCommandInGroupMessage('/takeover'));
194
+ return true;
195
+ }
196
+ const sessions = managedTakeoverCandidates(deps.workSessionStore, deps.assistantName);
197
+ const highlighted = deps.workSessionStore.getAssistantIndex(deps.assistantName)?.activeWorkSessionId || null;
198
+ await sendStatus(deps.send, message, workSessionThreadsMessage(sessions, { highlightedWorkSessionId: highlighted }));
199
+ return true;
200
+ }
201
+
202
+ if (command.type === 'codex') {
203
+ if (message.conversationRef.chatType === 'group') {
204
+ await sendStatus(deps.send, message, unsupportedHandoffCommandInGroupMessage('/codex'));
205
+ return true;
206
+ }
207
+ const linked = deps.syncLinkedWorkSessionThread(message.conversationRef, args.session.threadId);
208
+ if (!linked) {
209
+ await sendStatus(deps.send, message, codexBindingRequiredMessage());
210
+ return true;
211
+ }
212
+ const payload = await deps.handoffService.exportCurrentWorkSession(linked);
213
+ await sendStatus(deps.send, message, codexHandoffMessage(payload));
214
+ return true;
215
+ }
216
+
217
+ return false;
218
+ }