@fitlab-ai/agent-infra 0.10.0 → 0.11.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 (154) hide show
  1. package/README.md +2 -6
  2. package/README.zh-CN.md +2 -6
  3. package/bin/internal-cli.sh +0 -13
  4. package/dist/bin/internal-cli.js +11 -49
  5. package/dist/lib/internal/cli-route-inventory.js +3 -4
  6. package/dist/lib/internal/manual-validation.js +6 -2
  7. package/dist/lib/internal/platform-comment.js +60 -2
  8. package/dist/lib/internal/task-artifact.js +1 -1
  9. package/dist/lib/internal/task-finalization.js +2 -2
  10. package/dist/lib/internal/task-lifecycle.js +2 -2
  11. package/dist/lib/internal/task-operation-registry.js +22 -12
  12. package/dist/lib/internal/task-orchestration.js +2 -2
  13. package/dist/lib/internal/task-review.js +10 -5
  14. package/dist/lib/platform/activity-recovery.js +159 -0
  15. package/dist/lib/platform/issue-comments.js +84 -32
  16. package/dist/lib/platform/issues.js +3 -4
  17. package/dist/lib/platform/task-comment-projection.js +70 -0
  18. package/dist/lib/platform/task-recovery.js +55 -0
  19. package/dist/lib/platform/verification-sync.js +184 -162
  20. package/dist/lib/sandbox/control/executor.js +4 -2
  21. package/dist/lib/sandbox/control/task-workflow.js +1 -1
  22. package/dist/lib/sandbox/control/workflow-executor.js +90 -22
  23. package/dist/lib/server/process-state.js +17 -5
  24. package/dist/lib/task/artifact-command.js +6 -3
  25. package/dist/lib/task/artifact-lifecycle.js +15 -52
  26. package/dist/lib/task/artifact-operations.js +28 -2
  27. package/dist/lib/task/artifact-recovery.js +114 -68
  28. package/dist/lib/task/artifact-repair-intent.js +25 -8
  29. package/dist/lib/task/capabilities.js +0 -2
  30. package/dist/lib/task/control-authority.js +1 -1
  31. package/dist/lib/task/events.js +66 -49
  32. package/dist/lib/task/gate-policy.js +94 -0
  33. package/dist/lib/task/intermediate-cleanup.js +28 -3
  34. package/dist/lib/task/local-artifact-finalization.js +85 -18
  35. package/dist/lib/task/orchestration.js +8 -6
  36. package/dist/lib/task/review-artifacts.js +12 -6
  37. package/dist/lib/task/review-command.js +2 -2
  38. package/dist/lib/task/review-finalization.js +198 -11
  39. package/dist/lib/{host-control → task}/secure-fs.js +8 -19
  40. package/dist/lib/task/verification-engine.js +28 -17
  41. package/dist/lib/task/verification.js +25 -9
  42. package/dist/lib/task/workflow-command.js +2 -0
  43. package/dist/lib/task/workflow-dispatch.js +161 -0
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/internal/cli-route-inventory.ts +3 -4
  46. package/lib/internal/manual-validation.ts +6 -2
  47. package/lib/internal/platform-comment.ts +53 -2
  48. package/lib/internal/task-artifact.ts +1 -1
  49. package/lib/internal/task-finalization.ts +1 -1
  50. package/lib/internal/task-lifecycle.ts +1 -1
  51. package/lib/internal/task-operation-registry.ts +22 -12
  52. package/lib/internal/task-orchestration.ts +1 -1
  53. package/lib/internal/task-review.ts +9 -5
  54. package/lib/platform/activity-recovery.ts +179 -0
  55. package/lib/platform/issue-comments.ts +98 -31
  56. package/lib/platform/issues.ts +3 -4
  57. package/lib/platform/task-comment-projection.ts +79 -0
  58. package/lib/platform/task-recovery.ts +54 -0
  59. package/lib/platform/verification-sync.ts +203 -188
  60. package/lib/sandbox/control/executor.ts +4 -2
  61. package/lib/sandbox/control/protocol.ts +1 -1
  62. package/lib/sandbox/control/task-workflow.ts +1 -1
  63. package/lib/sandbox/control/workflow-executor.ts +113 -22
  64. package/lib/server/process-state.ts +26 -5
  65. package/lib/task/artifact-command.ts +6 -4
  66. package/lib/task/artifact-lifecycle.ts +12 -49
  67. package/lib/task/artifact-operations.ts +28 -2
  68. package/lib/task/artifact-recovery.ts +120 -67
  69. package/lib/task/artifact-repair-intent.ts +38 -11
  70. package/lib/task/capabilities.ts +0 -1
  71. package/lib/task/control-authority.ts +1 -1
  72. package/lib/task/events.ts +55 -38
  73. package/lib/task/gate-policy.ts +133 -0
  74. package/lib/task/intermediate-cleanup.ts +18 -2
  75. package/lib/task/local-artifact-finalization.ts +91 -15
  76. package/lib/task/orchestration.ts +12 -7
  77. package/lib/task/review-artifacts.ts +8 -2
  78. package/lib/task/review-command.ts +1 -1
  79. package/lib/task/review-finalization.ts +198 -9
  80. package/lib/{host-control → task}/secure-fs.ts +10 -32
  81. package/lib/task/verification-engine.ts +24 -14
  82. package/lib/task/verification-types.ts +7 -0
  83. package/lib/task/verification.ts +25 -9
  84. package/lib/task/workflow-command.ts +2 -0
  85. package/lib/task/workflow-dispatch.ts +171 -0
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/issue-sync.en.md +11 -1
  88. package/templates/.agents/rules/issue-sync.zh-CN.md +6 -2
  89. package/templates/.agents/rules/local-artifact-repair.en.md +18 -9
  90. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +21 -11
  91. package/templates/.agents/skills/analyze-task/SKILL.en.md +2 -2
  92. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  93. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -10
  94. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -10
  95. package/templates/.agents/skills/block-task/config/verify.json +1 -3
  96. package/templates/.agents/skills/cancel-task/config/verify.json +1 -4
  97. package/templates/.agents/skills/code-task/SKILL.en.md +4 -4
  98. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +5 -5
  99. package/templates/.agents/skills/code-task/config/verify.en.json +1 -12
  100. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -12
  101. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  102. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  103. package/templates/.agents/skills/code-task/reference/report-template.en.md +11 -0
  104. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +11 -0
  105. package/templates/.agents/skills/commit/config/verify.json +1 -5
  106. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +6 -6
  107. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +6 -6
  108. package/templates/.agents/skills/complete-task/config/verify.en.json +3 -14
  109. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +3 -14
  110. package/templates/.agents/skills/create-pr/config/verify.json +1 -10
  111. package/templates/.agents/skills/create-task/config/verify.json +1 -4
  112. package/templates/.agents/skills/import-issue/config/verify.json +1 -2
  113. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -2
  114. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  115. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -10
  116. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -10
  117. package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
  118. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +10 -5
  119. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  120. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  121. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -10
  122. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -10
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +2 -0
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +2 -0
  125. package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/review-code/config/verify.en.json +1 -10
  128. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -10
  129. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -0
  130. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -0
  131. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  132. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -10
  134. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -10
  135. package/templates/.agents/skills/review-plan/reference/report-template.en.md +2 -0
  136. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -8
  138. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -8
  139. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +38 -13
  140. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +38 -13
  141. package/dist/lib/host-control/audit.js +0 -19
  142. package/dist/lib/host-control/client.js +0 -142
  143. package/dist/lib/host-control/command.js +0 -49
  144. package/dist/lib/host-control/path.js +0 -160
  145. package/dist/lib/host-control/server.js +0 -159
  146. package/dist/lib/host-control/service.js +0 -112
  147. package/dist/lib/internal/host-control.js +0 -28
  148. package/lib/host-control/audit.ts +0 -22
  149. package/lib/host-control/client.ts +0 -172
  150. package/lib/host-control/command.ts +0 -60
  151. package/lib/host-control/path.ts +0 -167
  152. package/lib/host-control/server.ts +0 -180
  153. package/lib/host-control/service.ts +0 -133
  154. package/lib/internal/host-control.ts +0 -28
@@ -0,0 +1,179 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { locateActivityLog } from '../task/activity-log.ts';
4
+ import type { LogEntry } from '../task/activity-log.ts';
5
+
6
+ const METADATA_PREFIX = '<!-- activity-recovery:v1:';
7
+ const METADATA_SUFFIX = ' -->';
8
+
9
+ type ActivityRecoveryRecord = {
10
+ version: 1;
11
+ taskId: string;
12
+ artifact?: string;
13
+ entries: LogEntry[];
14
+ sha256: string;
15
+ };
16
+
17
+ function canonicalJson(value: unknown): string {
18
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
19
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
20
+ const record = value as Record<string, unknown>;
21
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
22
+ }
23
+
24
+ function sha256(value: unknown): string {
25
+ return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
26
+ }
27
+
28
+ function baseStep(step: string): string {
29
+ return step.replace(/\s*\[(?:started|aborted)\]\s*$/, '');
30
+ }
31
+
32
+ function entriesForArtifact(taskContent: string, artifact: string): LogEntry[] {
33
+ const log = locateActivityLog(taskContent);
34
+ if (!log) return [];
35
+ const selected = new Set<number>();
36
+ for (let index = 0; index < log.entries.length; index += 1) {
37
+ const entry = log.entries[index]!;
38
+ if (!entry.note.includes(`→ ${artifact}`)) continue;
39
+ selected.add(index);
40
+ const step = baseStep(entry.step);
41
+ let startedAt = index;
42
+ for (let prior = index - 1; prior >= 0; prior -= 1) {
43
+ const candidate = log.entries[prior]!;
44
+ if (baseStep(candidate.step) !== step) continue;
45
+ if (/\[started\]\s*$/.test(candidate.step)) {
46
+ selected.add(prior);
47
+ startedAt = prior;
48
+ }
49
+ break;
50
+ }
51
+ if (/^code(?:-r\d+)?\.md$/.test(artifact)) {
52
+ for (let between = startedAt + 1; between < index; between += 1) {
53
+ if (baseStep(log.entries[between]!.step) === 'Commit') selected.add(between);
54
+ }
55
+ }
56
+ }
57
+ return log.entries.filter((_entry, index) => selected.has(index));
58
+ }
59
+
60
+ function recordForEntries(taskId: string, entries: readonly LogEntry[], artifact?: string): ActivityRecoveryRecord | null {
61
+ if (entries.length === 0) return null;
62
+ const base = { version: 1 as const, taskId, ...(artifact ? { artifact } : {}), entries: [...entries] };
63
+ return { ...base, sha256: sha256(base) };
64
+ }
65
+
66
+ function recordForArtifact(taskId: string, taskContent: string, artifact: string): ActivityRecoveryRecord | null {
67
+ return recordForEntries(taskId, entriesForArtifact(taskContent, artifact), artifact);
68
+ }
69
+
70
+ function entriesForTask(taskContent: string): LogEntry[] {
71
+ const log = locateActivityLog(taskContent);
72
+ if (!log) return [];
73
+ const linked = new Set(entriesForArtifactNames(log.entries));
74
+ return log.entries.filter((entry, index) => !linked.has(index)
75
+ && !['Create PR', 'Complete Task', 'Cancel Task'].includes(baseStep(entry.step)));
76
+ }
77
+
78
+ function entriesForSummary(taskContent: string): LogEntry[] {
79
+ const log = locateActivityLog(taskContent);
80
+ return log?.entries.filter((entry) => ['Create PR', 'Complete Task'].includes(baseStep(entry.step))) ?? [];
81
+ }
82
+
83
+ function entriesForCancel(taskContent: string): LogEntry[] {
84
+ const log = locateActivityLog(taskContent);
85
+ return log?.entries.filter((entry) => baseStep(entry.step) === 'Cancel Task') ?? [];
86
+ }
87
+
88
+ function recordForTask(taskId: string, taskContent: string): ActivityRecoveryRecord | null {
89
+ return recordForEntries(taskId, entriesForTask(taskContent));
90
+ }
91
+
92
+ function recordForSummary(taskId: string, taskContent: string): ActivityRecoveryRecord | null {
93
+ return recordForEntries(taskId, entriesForSummary(taskContent));
94
+ }
95
+
96
+ function recordForCancel(taskId: string, taskContent: string): ActivityRecoveryRecord | null {
97
+ return recordForEntries(taskId, entriesForCancel(taskContent));
98
+ }
99
+
100
+ function entriesForArtifactNames(entries: readonly LogEntry[]): number[] {
101
+ const selected = new Set<number>();
102
+ for (let index = 0; index < entries.length; index += 1) {
103
+ const entry = entries[index]!;
104
+ const artifact = /→ ([A-Za-z0-9][A-Za-z0-9._-]*\.md)(?:\s|$)/.exec(entry.note)?.[1];
105
+ if (!artifact) continue;
106
+ selected.add(index);
107
+ const step = baseStep(entry.step);
108
+ let startedAt = index;
109
+ for (let prior = index - 1; prior >= 0; prior -= 1) {
110
+ const candidate = entries[prior]!;
111
+ if (baseStep(candidate.step) !== step) continue;
112
+ if (/\[started\]\s*$/.test(candidate.step)) {
113
+ selected.add(prior);
114
+ startedAt = prior;
115
+ }
116
+ break;
117
+ }
118
+ if (/^code(?:-r\d+)?\.md$/.test(artifact)) {
119
+ for (let between = startedAt + 1; between < index; between += 1) {
120
+ if (baseStep(entries[between]!.step) === 'Commit') selected.add(between);
121
+ }
122
+ }
123
+ }
124
+ return [...selected];
125
+ }
126
+
127
+ function renderActivityRecoveryMetadata(record: ActivityRecoveryRecord | null): string {
128
+ if (!record) return '';
129
+ const encoded = Buffer.from(canonicalJson(record), 'utf8').toString('base64url');
130
+ const first = record.entries[0]!;
131
+ const last = record.entries.at(-1)!;
132
+ return [
133
+ '<details><summary>恢复元数据</summary>',
134
+ '',
135
+ `- 动作:${baseStep(last.step)}`,
136
+ `- 记录:${record.entries.length} 条(${first.time} 至 ${last.time})`,
137
+ ...(record.artifact ? [`- 产物:${record.artifact}`] : []),
138
+ `- 校验:\`${record.sha256}\``,
139
+ '',
140
+ `${METADATA_PREFIX}${encoded}${METADATA_SUFFIX}`,
141
+ '</details>',
142
+ ''
143
+ ].join('\n');
144
+ }
145
+
146
+ function parseActivityRecoveryMetadata(body: string): ActivityRecoveryRecord | null {
147
+ const match = body.match(/<!-- activity-recovery:v1:([A-Za-z0-9_-]+) -->/);
148
+ if (!match) return null;
149
+ let value: unknown;
150
+ try { value = JSON.parse(Buffer.from(match[1]!, 'base64url').toString('utf8')); }
151
+ catch { throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata is not valid'); }
152
+ if (!value || typeof value !== 'object') throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata is invalid');
153
+ const record = value as Partial<ActivityRecoveryRecord>;
154
+ if (record.version !== 1 || typeof record.taskId !== 'string' || (record.artifact !== undefined && typeof record.artifact !== 'string')
155
+ || !Array.isArray(record.entries) || typeof record.sha256 !== 'string') {
156
+ throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata shape is invalid');
157
+ }
158
+ if (record.entries.some((entry) => !entry || typeof entry.time !== 'string' || typeof entry.step !== 'string' || typeof entry.agent !== 'string' || typeof entry.note !== 'string')) {
159
+ throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata entries are invalid');
160
+ }
161
+ const base = { version: 1 as const, taskId: record.taskId, ...(record.artifact ? { artifact: record.artifact } : {}), entries: record.entries as LogEntry[] };
162
+ if (sha256(base) !== record.sha256) throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata checksum does not match');
163
+ return { ...base, sha256: record.sha256 };
164
+ }
165
+
166
+ export {
167
+ entriesForArtifact,
168
+ entriesForCancel,
169
+ entriesForSummary,
170
+ entriesForTask,
171
+ parseActivityRecoveryMetadata,
172
+ recordForArtifact,
173
+ recordForCancel,
174
+ recordForEntries,
175
+ recordForSummary,
176
+ recordForTask,
177
+ renderActivityRecoveryMetadata
178
+ };
179
+ export type { ActivityRecoveryRecord };
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
 
4
5
  import { parseTaskFrontmatter } from '../task/frontmatter.ts';
5
6
  import { resolveTaskRef } from '../task/resolve-ref.ts';
@@ -16,6 +17,14 @@ import {
16
17
  } from './provider-bridge.ts';
17
18
  import { resourceIdentityNumber } from './resource-identity.ts';
18
19
  import { taskIssueIdentity, taskIssueIdentityError } from './task-identities.ts';
20
+ import {
21
+ recordForArtifact,
22
+ recordForCancel,
23
+ recordForSummary,
24
+ recordForTask,
25
+ renderActivityRecoveryMetadata
26
+ } from './activity-recovery.ts';
27
+ import { projectTaskComment } from './task-comment-projection.ts';
19
28
  import {
20
29
  canonicalizeCommentBody,
21
30
  escapeHtmlText,
@@ -48,7 +57,7 @@ const MARKERS = {
48
57
  artifactChunk: (taskId: string, stem: string, part: number, total: number) =>
49
58
  `<!-- sync-issue:${taskId}:${stem}:${part}/${total} -->`,
50
59
  summary: (taskId: string) => `<!-- sync-issue:${taskId}:summary -->`,
51
- cancel: (taskId: string) => `<!-- sync-issue:${taskId}:cancel -->`
60
+ cancel: (taskId: string) => `<!-- sync-issue:${taskId}:cancel -->`,
52
61
  };
53
62
  const COMMENT_BYTE_LIMIT = 60_000;
54
63
 
@@ -68,10 +77,6 @@ function normalizeCommentContent(content: string): string {
68
77
  return content.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
69
78
  }
70
79
 
71
- function isTaskCommentTooLarge(content: string): boolean {
72
- return Buffer.byteLength(content, 'utf8') > COMMENT_BYTE_LIMIT;
73
- }
74
-
75
80
  function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
76
81
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
77
82
  if (!match) return { frontmatter: null, body: content };
@@ -88,22 +93,55 @@ function footer(agent: string, taskId: string): string {
88
93
  return `---\n*由 ${agent} 自动生成 · 内部追踪:${taskId}*`;
89
94
  }
90
95
 
91
- function renderTaskComment(content: string, taskId: string, agent: string): string {
96
+ function taskFrontmatterSummary(language = ''): string {
97
+ return language === 'en' || language === 'en-US'
98
+ ? 'Metadata (frontmatter)'
99
+ : '元数据 (frontmatter)';
100
+ }
101
+
102
+ function renderTaskCommentResult(content: string, taskId: string, language = ''): { body: string; byteLength: number; sha256: string } {
92
103
  const split = splitFrontmatter(content);
93
- const safeBody = sanitizeCommentBody(split.body, 'task body');
94
- const taskBody = split.frontmatter === null
104
+ const projectedContent = split.frontmatter === null ? content : projectTaskComment(content).content;
105
+ const projected = splitFrontmatter(projectedContent);
106
+ const safeBody = sanitizeCommentBody(projected.body, 'task body');
107
+ const taskBody = projected.frontmatter === null
95
108
  ? safeBody
96
- : `<details><summary>元数据 (frontmatter)</summary>\n\n${renderSafeCodeFence(`---\n${split.frontmatter}\n---`, 'yaml')}\n\n</details>\n\n${safeBody}`;
97
- return normalizeCommentContent([
109
+ : `<details><summary>${taskFrontmatterSummary(language)}</summary>\n\n${renderSafeCodeFence(`---\n${projected.frontmatter}\n---`, 'yaml')}\n\n</details>\n\n${safeBody}`;
110
+ const recoveryMetadata = renderActivityRecoveryMetadata(recordForTask(taskId, content));
111
+ const body = normalizeCommentContent([
98
112
  MARKERS.task(taskId),
99
113
  '## 任务文件',
100
114
  '',
101
- `> **${agent}** · ${taskId}`,
115
+ `> 任务同步 · ${taskId}`,
102
116
  '',
117
+ ...(recoveryMetadata ? [recoveryMetadata.replace(/\n+$/, ''), ''] : []),
103
118
  taskBody.replace(/\n+$/, ''),
104
119
  '',
105
- footer(agent, taskId)
120
+ '---',
121
+ `*由 agent-infra 自动生成 · 内部追踪:${taskId}*`
106
122
  ].join('\n'));
123
+ return {
124
+ body,
125
+ byteLength: Buffer.byteLength(body, 'utf8'),
126
+ sha256: createHash('sha256').update(body, 'utf8').digest('hex')
127
+ };
128
+ }
129
+
130
+ function renderTaskComment(content: string, taskId: string, _agent: string, language = ''): string {
131
+ return renderTaskCommentResult(content, taskId, language).body;
132
+ }
133
+
134
+ function isTaskCommentTooLarge(content: string, taskId = 'TASK-UNKNOWN', _agent = 'codex'): boolean {
135
+ return renderTaskCommentResult(content, taskId).byteLength > COMMENT_BYTE_LIMIT;
136
+ }
137
+
138
+ function taskCommentLanguage(repoRoot: string): string {
139
+ try {
140
+ const config = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')) as { language?: unknown };
141
+ return typeof config.language === 'string' ? config.language.trim() : '';
142
+ } catch {
143
+ return '';
144
+ }
107
145
  }
108
146
 
109
147
  function artifactIdentity(artifact: string): { stem: string; title: string } {
@@ -247,7 +285,8 @@ function buildArtifactChunk(
247
285
  total: number,
248
286
  chunked: boolean,
249
287
  backfill: boolean,
250
- renderedContent = content
288
+ renderedContent = content,
289
+ recoveryMetadata = ''
251
290
  ): RenderedChunk {
252
291
  const marker = chunked ? MARKERS.artifactChunk(taskId, stem, part, total) : MARKERS.artifact(taskId, stem);
253
292
  const heading = chunked ? `## ${title}(${part}/${total})` : `## ${title}`;
@@ -259,6 +298,7 @@ function buildArtifactChunk(
259
298
  '',
260
299
  `> **${agent}** · ${taskId}`,
261
300
  '',
301
+ ...(recoveryMetadata ? [recoveryMetadata] : []),
262
302
  renderedContent,
263
303
  '',
264
304
  footer(agent, taskId)
@@ -273,12 +313,13 @@ function chunkArtifactComment(input: {
273
313
  body: string;
274
314
  byteLimit?: number;
275
315
  backfill?: boolean;
316
+ recoveryMetadata?: string;
276
317
  }): RenderedChunk[] {
277
318
  const byteLimit = input.byteLimit || COMMENT_BYTE_LIMIT;
278
319
  const identity = artifactIdentity(input.artifact);
279
320
  const safeBody = sanitizeCommentBody(input.body, 'artifact body');
280
321
  const single = buildArtifactChunk(
281
- input.taskId, identity.stem, identity.title, input.agent, safeBody, 1, 1, false, Boolean(input.backfill)
322
+ input.taskId, identity.stem, identity.title, input.agent, safeBody, 1, 1, false, Boolean(input.backfill), safeBody, input.recoveryMetadata
282
323
  );
283
324
  if (Buffer.byteLength(single.body, 'utf8') <= byteLimit) return [single];
284
325
 
@@ -288,7 +329,7 @@ function chunkArtifactComment(input: {
288
329
  if (!ranges.ok) throw new Error(`${ranges.error.code}: artifact body: ${ranges.error.message} at offset ${ranges.error.offset}`);
289
330
  for (;;) {
290
331
  const probe = buildArtifactChunk(
291
- input.taskId, identity.stem, identity.title, input.agent, '', total, total, true, Boolean(input.backfill)
332
+ input.taskId, identity.stem, identity.title, input.agent, '', 1, total, true, Boolean(input.backfill), '', input.recoveryMetadata
292
333
  );
293
334
  const available = byteLimit - Buffer.byteLength(probe.body, 'utf8');
294
335
  const sourceLimit = Math.min(available, payloadLimit);
@@ -308,7 +349,8 @@ function chunkArtifactComment(input: {
308
349
  total,
309
350
  true,
310
351
  Boolean(input.backfill),
311
- independentChunkContent(piece, ranges.value, sourceLimit)
352
+ independentChunkContent(piece, ranges.value, sourceLimit),
353
+ index === 0 ? input.recoveryMetadata : ''
312
354
  ));
313
355
  const overflow = Math.max(...chunks.map((chunk) => Buffer.byteLength(chunk.body, 'utf8') - byteLimit));
314
356
  if (overflow <= 0) return chunks;
@@ -346,9 +388,11 @@ function hasResolvedPlatformContext(context: PlatformResult): boolean {
346
388
  return context.status === 'degraded' || (context.status === 'no-op' && context.error === null);
347
389
  }
348
390
 
349
- function bodyEnvelope(marker: string, title: string, taskId: string, agent: string, body: string): string {
391
+ function bodyEnvelope(marker: string, title: string, taskId: string, agent: string, body: string, recoveryMetadata = ''): string {
350
392
  return normalizeCommentContent([
351
- marker, `## ${title}`, '', `> **${agent}** · ${taskId}`, '', sanitizeCommentBody(body, `${title} body`).replace(/\n+$/, ''), '', footer(agent, taskId)
393
+ marker, `## ${title}`, '', `> **${agent}** · ${taskId}`, '',
394
+ ...(recoveryMetadata ? [recoveryMetadata.replace(/\n+$/, ''), ''] : []),
395
+ sanitizeCommentBody(body, `${title} body`).replace(/\n+$/, ''), '', footer(agent, taskId)
352
396
  ].join('\n'));
353
397
  }
354
398
 
@@ -368,10 +412,11 @@ function expectedComments(
368
412
  taskId: string,
369
413
  taskContent: string,
370
414
  taskDir: string,
415
+ repoRoot: string,
371
416
  options: SyncOptions
372
417
  ): RenderedChunk[] {
373
418
  if (options.kind === 'task') {
374
- const body = renderTaskComment(taskContent, taskId, options.agent);
419
+ const body = renderTaskComment(taskContent, taskId, options.agent, taskCommentLanguage(repoRoot));
375
420
  return [{ marker: MARKERS.task(taskId), body, content: taskContent, part: 1, total: 1 }];
376
421
  }
377
422
  if (options.kind === 'artifact') {
@@ -381,13 +426,17 @@ function expectedComments(
381
426
  artifact: options.artifact,
382
427
  agent: options.agent,
383
428
  body: resolveArtifactBody(taskDir, options.artifact),
384
- backfill: options.backfill
429
+ backfill: options.backfill,
430
+ recoveryMetadata: renderActivityRecoveryMetadata(recordForArtifact(taskId, taskContent, options.artifact))
385
431
  });
386
432
  }
387
433
  if (options.body === undefined) throw new Error(`${options.kind} sync requires a body`);
388
434
  const marker = options.kind === 'summary' ? MARKERS.summary(taskId) : MARKERS.cancel(taskId);
389
435
  const title = options.kind === 'summary' ? '交付摘要' : '任务取消';
390
- const body = bodyEnvelope(marker, title, taskId, options.agent, options.body);
436
+ const recoveryMetadata = options.kind === 'summary'
437
+ ? renderActivityRecoveryMetadata(recordForSummary(taskId, taskContent))
438
+ : renderActivityRecoveryMetadata(recordForCancel(taskId, taskContent));
439
+ const body = bodyEnvelope(marker, title, taskId, options.agent, options.body, recoveryMetadata);
391
440
  return [{ marker, body, content: options.body, part: 1, total: 1 }];
392
441
  }
393
442
 
@@ -458,22 +507,22 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
458
507
  error: { code: 'ISSUE_NOT_LINKED', message: 'Task has no valid platform issue identity', retryable: false }
459
508
  });
460
509
  }
461
- if (options.kind === 'task' && isTaskCommentTooLarge(taskContent)) {
462
- return platformResult('no-op', {
463
- resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask), identity: issueIdentityFromTask },
464
- operations: [{ name: `comment:${MARKERS.task(resolved.taskId)}`, status: 'skipped', reasonCode: 'COMMENT_PAYLOAD_TOO_LARGE' }],
465
- error: null
466
- });
467
- }
468
510
  let desired: RenderedChunk[];
469
511
  try {
470
- desired = expectedComments(resolved.taskId, taskContent, resolved.taskDir, options);
512
+ desired = expectedComments(resolved.taskId, taskContent, resolved.taskDir, resolved.repoRoot, options);
471
513
  } catch (error) {
472
514
  return platformResult('failed', {
473
515
  resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask) },
474
516
  error: { code: 'COMMENT_PAYLOAD_INVALID', message: error instanceof Error ? error.message : String(error), retryable: false }
475
517
  });
476
518
  }
519
+ if (options.kind === 'task' && Buffer.byteLength(desired[0]!.body, 'utf8') > COMMENT_BYTE_LIMIT) {
520
+ return platformResult('failed', {
521
+ resource: { kind: 'issue', number: resourceIdentityNumber(issueIdentityFromTask), identity: issueIdentityFromTask },
522
+ operations: [{ name: `comment:${MARKERS.task(resolved.taskId)}`, status: 'skipped', reasonCode: 'COMMENT_PAYLOAD_TOO_LARGE' }],
523
+ error: { code: 'COMMENT_PAYLOAD_TOO_LARGE', message: 'Projected task comment exceeds the platform byte limit', retryable: false }
524
+ });
525
+ }
477
526
  const loaded = await resolvePlatformProviderContext({ cwd: resolved.repoRoot, client: options.client });
478
527
  const context = loaded.ok ? loaded.value.context : loaded.context;
479
528
  if (!hasResolvedPlatformContext(context) || !loaded.ok) return context;
@@ -505,6 +554,22 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
505
554
  });
506
555
  }
507
556
 
557
+ if (options.kind === 'task' && existing.length === 1) {
558
+ const owner = existing[0]!.user?.login;
559
+ if (!owner || !context.platform.currentUser || owner !== context.platform.currentUser) {
560
+ return platformResult('blocked', {
561
+ ...contextFields(context),
562
+ resource: { kind: 'issue', number: issue, identity: issueIdentityFromTask },
563
+ error: {
564
+ code: 'COMMENT_OWNER_CONFLICT',
565
+ message: owner ? `Task comment is owned by '${owner}'` : 'Task comment owner is unavailable',
566
+ retryable: false
567
+ }
568
+ });
569
+ }
570
+ }
571
+
572
+
508
573
  // Backfill only supplies missing artifact comments; valid existing marker sets stay untouched.
509
574
  if (options.kind === 'artifact' && options.backfill && existing.length > 0) {
510
575
  const operations = existing.map((comment): PlatformOperation => ({
@@ -582,14 +647,15 @@ async function syncPlatformComment(taskRef: string, options: SyncOptions): Promi
582
647
  operations.push({ name: `comment:${comment.id}`, status: 'applied', reasonCode: 'STALE_CHUNK_DELETED' });
583
648
  }
584
649
  const changed = operations.some((operation) => operation.status === 'applied');
585
- return platformResult(changed ? 'applied' : 'no-op', {
650
+ const result = platformResult(changed ? 'applied' : 'no-op', {
586
651
  ...contextFields(context),
587
652
  changed,
588
653
  resource: { kind: 'issue', number: issue },
589
654
  operations,
590
- comment: { kind: options.kind, marker: desired[0]!.marker, ids, parts: desired.length },
655
+ comment: { kind: options.kind, marker: desired[0]?.marker ?? markerPrefix(resolved.taskId, options), ids, parts: desired.length },
591
656
  error: null
592
657
  });
658
+ return result;
593
659
  }
594
660
 
595
661
  async function listPlatformComments(issue: string | number, cwd = process.cwd(), client?: PlatformClient): Promise<PlatformResult & { comments?: RemoteComment[] }> {
@@ -655,6 +721,7 @@ export {
655
721
  listPlatformComments,
656
722
  normalizeCommentContent,
657
723
  renderTaskComment,
724
+ renderTaskCommentResult,
658
725
  isTaskCommentTooLarge,
659
726
  syncPlatformComment,
660
727
  validateRelatedMarkerSet,
@@ -574,9 +574,8 @@ async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise
574
574
  const failure = plan.operations.find((operation) => operation.status === 'failed');
575
575
  if (failure) return result('failed', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: { code: failure.reasonCode || 'ISSUE_SYNC_FAILED', message: `Operation ${failure.name} failed`, retryable: false } });
576
576
  const planned = plan.operations.filter((operation) => operation.status === 'planned');
577
- const skipped = plan.operations.some((operation) => operation.status === 'skipped');
578
- if (options.dryRun) return result(planned.length ? 'planned' : skipped ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
579
- if (planned.length === 0) return result(skipped ? 'degraded' : 'no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
577
+ if (options.dryRun) return result(planned.length ? 'planned' : 'no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
578
+ if (planned.length === 0) return result('no-op', base.resolved.taskId, base.issueNumber, { issue: snapshot, operations: plan.operations, error: null });
580
579
  const payload = applyRestOperations(snapshot, planned) as Partial<{
581
580
  title: string; body: string; labels: string[]; assignees: string[];
582
581
  milestone: string | null; state: 'open' | 'closed'; fields: Record<string, string | number | null>;
@@ -611,7 +610,7 @@ async function syncPlatformIssue(taskRef: string, options: SyncOptions): Promise
611
610
  error: { code: 'IN_LABEL_SYNC_PARTIAL', message: 'Issue labels did not converge after update', retryable: true }
612
611
  });
613
612
  }
614
- return result(skipped ? 'degraded' : 'applied', base.resolved.taskId, base.issueNumber, {
613
+ return result('applied', base.resolved.taskId, base.issueNumber, {
615
614
  changed: updated.value.changed, platform: base.context.platform, capabilities: base.context.capabilities,
616
615
  resource: { kind: 'issue', number: base.issueNumber, identity: base.issueIdentity }, issue: finalIssue,
617
616
  operations: plan.operations.map((operation) => operation.status === 'planned' ? { ...operation, status: 'applied' } : operation), error: null
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const SNAPSHOT_FRONTMATTER = new Set([
4
+ 'id', 'type', 'branch', 'workflow', 'status', 'created_at', 'updated_at',
5
+ 'priority', 'effort', 'start_date', 'target_date', 'current_step', 'assigned_to',
6
+ 'platform_issue_identity', 'delivery_remote', 'delivery_base_ref', 'pr_delivery_fact'
7
+ ]);
8
+
9
+ type TaskCommentProjection = {
10
+ content: string;
11
+ byteLength: number;
12
+ sha256: string;
13
+ };
14
+
15
+ const PROCESS_SECTION_ALIASES = new Set([
16
+ '活动日志', 'activity log',
17
+ '产物生命周期收据', 'artifact lifecycle receipts',
18
+ '审查分歧账本', 'review dispute ledger', 'review disagreement ledger',
19
+ '人工裁决', '人工裁决待办', 'human decisions', 'human decision pending', 'human decisions pending',
20
+ '实现输入', 'implementation input', 'implementation inputs',
21
+ '工作流告警', 'workflow warning', 'workflow warnings', 'warnings',
22
+ '返工意图', 'rework intent', 'rework intents',
23
+ '产物失效记录', 'artifact invalidation', 'artifact invalidation records',
24
+ '实现备注', 'implementation notes',
25
+ '审查反馈', 'review feedback'
26
+ ]);
27
+
28
+ function normalize(content: string): string {
29
+ return content.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
30
+ }
31
+
32
+ function splitFrontmatter(content: string): { frontmatter: string; body: string } {
33
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
34
+ if (!match) throw new Error('task comment projection requires task frontmatter');
35
+ return { frontmatter: match[1]!, body: content.slice(match[0].length) };
36
+ }
37
+
38
+ function projectFrontmatter(frontmatter: string): string {
39
+ const lines = frontmatter.replace(/\r\n/g, '\n').split('\n');
40
+ const selected = lines.filter((line) => {
41
+ if (!line.trim() || line.trimStart().startsWith('#')) return false;
42
+ const match = /^([^:\s][^:]*):/.exec(line);
43
+ return Boolean(match && SNAPSHOT_FRONTMATTER.has(match[1]!.trim()));
44
+ });
45
+ if (!selected.some((line) => /^id:/.test(line))) throw new Error('task comment projection requires frontmatter id');
46
+ return selected.join('\n');
47
+ }
48
+
49
+ function projectBody(body: string): string {
50
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
51
+ const kept: string[] = [];
52
+ let excludedLevel: number | null = null;
53
+ for (const line of lines) {
54
+ const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
55
+ if (heading) {
56
+ const level = heading[1]!.length;
57
+ if (excludedLevel !== null && level <= excludedLevel) excludedLevel = null;
58
+ if (PROCESS_SECTION_ALIASES.has(heading[2]!.trim().toLowerCase())) {
59
+ excludedLevel = level;
60
+ continue;
61
+ }
62
+ }
63
+ if (excludedLevel === null) kept.push(line);
64
+ }
65
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
66
+ }
67
+
68
+ function projectTaskComment(content: string): TaskCommentProjection {
69
+ const split = splitFrontmatter(content);
70
+ const projected = normalize(`---\n${projectFrontmatter(split.frontmatter)}\n---\n\n${projectBody(split.body)}`);
71
+ return {
72
+ content: projected,
73
+ byteLength: Buffer.byteLength(projected, 'utf8'),
74
+ sha256: createHash('sha256').update(projected, 'utf8').digest('hex')
75
+ };
76
+ }
77
+
78
+ export { projectTaskComment };
79
+ export type { TaskCommentProjection };
@@ -0,0 +1,54 @@
1
+ import { parseActivityRecoveryMetadata } from './activity-recovery.ts';
2
+
3
+ type RecoveryRemoteComment = { body: string; user?: { login?: string } };
4
+
5
+ function normalized(body: string): string {
6
+ return body.replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
7
+ }
8
+
9
+ function decodeRenderedText(value: string): string {
10
+ return value
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&#39;/g, "'")
15
+ .replace(/&amp;/g, '&');
16
+ }
17
+
18
+ function taskSnapshot(taskId: string, body: string): string {
19
+ const marker = `<!-- sync-issue:${taskId}:task -->`;
20
+ if (normalized(body).split('\n', 1)[0] !== marker) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot marker is invalid');
21
+ const frontmatter = body.match(/<details><summary>[^<]*<\/summary>\n\n```yaml\n---\n([\s\S]*?)\n---\n```\n\n<\/details>/);
22
+ if (!frontmatter) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot frontmatter is invalid');
23
+ const afterDetails = (frontmatter.index ?? 0) + frontmatter[0].length;
24
+ const footer = body.lastIndexOf('\n\n---\n*由 ');
25
+ if (footer <= afterDetails) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot envelope is invalid');
26
+ const projectedBody = body.slice(afterDetails, footer).replace(/^\n+|\n+$/g, '');
27
+ return `---\n${decodeRenderedText(frontmatter[1]!)}\n---\n\n${decodeRenderedText(projectedBody)}\n`;
28
+ }
29
+
30
+ function activityLine(entry: { time: string; step: string; agent: string; note: string }): string {
31
+ return `- ${entry.time} — **${entry.step}** by ${entry.agent} — ${entry.note}`;
32
+ }
33
+
34
+ function recoverTaskFromComments(input: { taskId: string; comments: readonly RecoveryRemoteComment[] }): string {
35
+ const marker = `<!-- sync-issue:${input.taskId}:task -->`;
36
+ const snapshots = input.comments.filter((comment) => normalized(comment.body).split('\n', 1)[0] === marker);
37
+ if (snapshots.length !== 1) throw new Error('RECOVERY_EVIDENCE_MISSING: exactly one task snapshot comment is required');
38
+ const author = snapshots[0]!.user?.login;
39
+ if (!author) throw new Error('RECOVERY_EVIDENCE_MISSING: task snapshot author is unavailable');
40
+ const content = taskSnapshot(input.taskId, normalized(snapshots[0]!.body));
41
+ const entries = new Map<string, { time: string; step: string; agent: string; note: string }>();
42
+ for (const comment of input.comments) {
43
+ if (comment.user?.login !== author) continue;
44
+ const record = parseActivityRecoveryMetadata(comment.body);
45
+ if (!record) continue;
46
+ if (record.taskId !== input.taskId) throw new Error('RECOVERY_EVIDENCE_MISSING: activity metadata task identity is invalid');
47
+ for (const entry of record.entries) entries.set(JSON.stringify(entry), entry);
48
+ }
49
+ const restored = [...entries.values()].sort((left, right) => left.time.localeCompare(right.time));
50
+ return `${content.replace(/\n+$/, '')}\n\n## 活动日志\n\n${restored.map(activityLine).join('\n')}\n`;
51
+ }
52
+
53
+ export { recoverTaskFromComments };
54
+ export type { RecoveryRemoteComment };