@fitlab-ai/agent-infra 0.9.6 → 0.9.8

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 (178) hide show
  1. package/dist/bin/internal-cli.js +10 -0
  2. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +47 -5
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +245 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +398 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +23 -6
  8. package/dist/lib/decide.js +68 -14
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -3
  11. package/dist/lib/init.js +1 -1
  12. package/dist/lib/internal/codex-lifecycle.js +69 -1
  13. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  14. package/dist/lib/internal/release-workflow.js +1 -3
  15. package/dist/lib/internal/task-activity.js +57 -7
  16. package/dist/lib/internal/task-event.js +57 -4
  17. package/dist/lib/internal/task-ledger.js +59 -5
  18. package/dist/lib/internal/task-lifecycle.js +59 -8
  19. package/dist/lib/internal/task-orchestration.js +48 -8
  20. package/dist/lib/internal/task-override.js +131 -0
  21. package/dist/lib/internal/task-review.js +64 -6
  22. package/dist/lib/internal/task-verify.js +65 -3
  23. package/dist/lib/internal/task-warning.js +57 -4
  24. package/dist/lib/platform/context.js +6 -4
  25. package/dist/lib/platform/github-client.js +55 -2
  26. package/dist/lib/platform/pr-summary.js +5 -3
  27. package/dist/lib/process-data/checkpoint.js +319 -0
  28. package/dist/lib/process-data/index.js +128 -60
  29. package/dist/lib/process-data/materialize.js +84 -0
  30. package/dist/lib/process-data/normalize.js +34 -1
  31. package/dist/lib/process-data/reconcile.js +55 -1
  32. package/dist/lib/process-data/sources.js +422 -1
  33. package/dist/lib/process-data/store.js +341 -11
  34. package/dist/lib/runtime/agent-runtime.js +56 -0
  35. package/dist/lib/sandbox/commands/create.js +394 -264
  36. package/dist/lib/sandbox/commands/rm.js +64 -15
  37. package/dist/lib/sandbox/commands/start.js +4 -3
  38. package/dist/lib/sandbox/control/container-identity.js +64 -0
  39. package/dist/lib/sandbox/control/executor.js +23 -9
  40. package/dist/lib/sandbox/control/lifecycle.js +590 -61
  41. package/dist/lib/sandbox/control/protocol.js +7 -0
  42. package/dist/lib/sandbox/control/server.js +202 -26
  43. package/dist/lib/sandbox/control/state.js +51 -25
  44. package/dist/lib/sandbox/control/timing.js +8 -0
  45. package/dist/lib/sandbox/engine.js +14 -7
  46. package/dist/lib/sandbox/mounts.js +7 -0
  47. package/dist/lib/sandbox/recovery.js +135 -50
  48. package/dist/lib/sandbox/workspace-view.js +59 -20
  49. package/dist/lib/server/daemon.js +1 -1
  50. package/dist/lib/server/process-control.js +11 -1
  51. package/dist/lib/server/process-state.js +107 -20
  52. package/dist/lib/task/activity-intent.js +4 -1
  53. package/dist/lib/task/artifact-lifecycle.js +82 -11
  54. package/dist/lib/task/artifact-receipts.js +128 -0
  55. package/dist/lib/task/codex-orchestration.js +81 -6
  56. package/dist/lib/task/commands/validate.js +1 -1
  57. package/dist/lib/task/create.js +10 -2
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +120 -17
  60. package/dist/lib/task/events.js +148 -7
  61. package/dist/lib/task/guard-override.js +31 -0
  62. package/dist/lib/task/human-override.js +1240 -0
  63. package/dist/lib/task/ledger-intents.js +6 -3
  64. package/dist/lib/task/ledger.js +13 -1
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/orchestration.js +293 -18
  67. package/dist/lib/task/review-finalization.js +3 -2
  68. package/dist/lib/task/sections.js +5 -1
  69. package/dist/lib/task/task-execution-lock.js +5 -4
  70. package/dist/lib/task/verification-engine.js +80 -22
  71. package/dist/lib/task/verification.js +13 -1
  72. package/dist/lib/task/workflow-warning-intents.js +4 -2
  73. package/dist/lib/task/write.js +2 -1
  74. package/dist/lifecycle-build-manifest.json +6 -0
  75. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  76. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +62 -4
  77. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  78. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +322 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  80. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +480 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +35 -5
  82. package/lib/decide.ts +59 -14
  83. package/lib/defaults.json +10 -2
  84. package/lib/git/workflow.ts +25 -2
  85. package/lib/init.ts +1 -1
  86. package/lib/internal/codex-lifecycle.ts +68 -1
  87. package/lib/internal/codex-sandbox-controller.ts +109 -0
  88. package/lib/internal/release-workflow.ts +1 -3
  89. package/lib/internal/task-activity.ts +46 -6
  90. package/lib/internal/task-event.ts +53 -4
  91. package/lib/internal/task-ledger.ts +48 -4
  92. package/lib/internal/task-lifecycle.ts +56 -12
  93. package/lib/internal/task-orchestration.ts +43 -8
  94. package/lib/internal/task-override.ts +138 -0
  95. package/lib/internal/task-review.ts +51 -6
  96. package/lib/internal/task-verify.ts +52 -3
  97. package/lib/internal/task-warning.ts +44 -3
  98. package/lib/platform/context.ts +8 -5
  99. package/lib/platform/github-client.ts +61 -3
  100. package/lib/platform/pr-summary.ts +10 -3
  101. package/lib/process-data/checkpoint.ts +335 -0
  102. package/lib/process-data/index.ts +112 -60
  103. package/lib/process-data/materialize.ts +83 -0
  104. package/lib/process-data/normalize.ts +29 -1
  105. package/lib/process-data/reconcile.ts +64 -2
  106. package/lib/process-data/sources.ts +449 -2
  107. package/lib/process-data/store.ts +304 -11
  108. package/lib/process-data/types.ts +124 -2
  109. package/lib/runtime/agent-runtime.ts +76 -0
  110. package/lib/sandbox/commands/create.ts +139 -6
  111. package/lib/sandbox/commands/rm.ts +64 -14
  112. package/lib/sandbox/commands/start.ts +4 -3
  113. package/lib/sandbox/config.ts +2 -1
  114. package/lib/sandbox/control/container-identity.ts +93 -0
  115. package/lib/sandbox/control/executor.ts +24 -12
  116. package/lib/sandbox/control/lifecycle.ts +685 -67
  117. package/lib/sandbox/control/protocol.ts +35 -6
  118. package/lib/sandbox/control/server.ts +184 -24
  119. package/lib/sandbox/control/state.ts +67 -33
  120. package/lib/sandbox/control/timing.ts +12 -0
  121. package/lib/sandbox/engine.ts +20 -9
  122. package/lib/sandbox/mounts.ts +8 -0
  123. package/lib/sandbox/recovery.ts +159 -49
  124. package/lib/sandbox/workspace-view.ts +73 -22
  125. package/lib/server/daemon.ts +1 -1
  126. package/lib/server/process-control.ts +13 -3
  127. package/lib/server/process-state.ts +129 -27
  128. package/lib/task/activity-intent.ts +4 -2
  129. package/lib/task/artifact-lifecycle.ts +73 -10
  130. package/lib/task/artifact-receipts.ts +161 -0
  131. package/lib/task/codex-orchestration.ts +91 -5
  132. package/lib/task/commands/validate.ts +1 -1
  133. package/lib/task/create.ts +11 -2
  134. package/lib/task/decision-intents.ts +4 -2
  135. package/lib/task/delegation-receipts.ts +205 -25
  136. package/lib/task/events.ts +132 -6
  137. package/lib/task/guard-override.ts +50 -0
  138. package/lib/task/human-override.ts +1503 -0
  139. package/lib/task/ledger-intents.ts +6 -3
  140. package/lib/task/ledger.ts +16 -1
  141. package/lib/task/lifecycle.ts +71 -14
  142. package/lib/task/orchestration.ts +443 -22
  143. package/lib/task/review-finalization.ts +6 -2
  144. package/lib/task/sections.ts +6 -2
  145. package/lib/task/task-execution-lock.ts +11 -9
  146. package/lib/task/verification-engine.ts +93 -32
  147. package/lib/task/verification.ts +15 -1
  148. package/lib/task/workflow-warning-intents.ts +4 -2
  149. package/lib/task/write.ts +4 -1
  150. package/package.json +1 -1
  151. package/templates/.agents/README.en.md +7 -0
  152. package/templates/.agents/README.zh-CN.md +5 -0
  153. package/templates/.agents/hooks/lifecycle-delegation.js +33 -4
  154. package/templates/.agents/rules/lifecycle-orchestration.en.md +10 -3
  155. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +10 -3
  156. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  157. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  158. package/templates/.agents/rules/review-handshake.en.md +3 -1
  159. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  160. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  161. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  162. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  163. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  164. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
  165. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
  166. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
  167. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
  168. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  169. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  170. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
  171. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
  172. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  173. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  174. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -1
  175. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -1
  176. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  177. package/templates/.agents/workspace/README.en.md +0 -26
  178. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -139,7 +139,7 @@ export async function runDaemon(): Promise<void> {
139
139
  logger.close();
140
140
  if (ownStartTime !== null) {
141
141
  removePidRecordIfMatches(config.pidFile, {
142
- version: 1,
142
+ version: 2,
143
143
  pid: process.pid,
144
144
  startTime: ownStartTime
145
145
  });
@@ -11,7 +11,7 @@ import {
11
11
  removePidFileIfMatches,
12
12
  writePidRecord
13
13
  } from './process-state.ts';
14
- import type { PidRecord } from './process-state.ts';
14
+ import type { PidRecord, ProcessIdentity, ProcessState } from './process-state.ts';
15
15
 
16
16
  export { isProcessAlive } from './process-state.ts';
17
17
 
@@ -51,13 +51,13 @@ async function waitForProcessIdentity(pid: number, timeoutMs = 2000): Promise<Pi
51
51
  const deadline = Date.now() + timeoutMs;
52
52
  while (Date.now() < deadline) {
53
53
  const startTime = getProcessStartTime(pid);
54
- if (startTime !== null) return { version: 1, pid, startTime };
54
+ if (startTime !== null) return { version: 2, pid, startTime };
55
55
  await delay(25);
56
56
  }
57
57
  return null;
58
58
  }
59
59
 
60
- function terminateIfMatching(record: PidRecord): void {
60
+ function terminateIfMatching(record: ProcessIdentity): void {
61
61
  if (!processIdentityMatches(record)) return;
62
62
  const command = buildStopCommand(record.pid, process.platform);
63
63
  try {
@@ -83,6 +83,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
83
83
  process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
84
84
  return;
85
85
  }
86
+ if (existing.kind === 'legacy-json' || existing.kind === 'legacy-pid-only' || existing.kind === 'legacy-unknown') {
87
+ throw new Error('server: legacy pid record requires the matching previous version to stop it before starting');
88
+ }
86
89
  if (existing.kind !== 'missing') {
87
90
  removePidFileIfMatches(pidPath, existing.snapshot);
88
91
  }
@@ -135,6 +138,9 @@ export async function stop(): Promise<void> {
135
138
  return;
136
139
  }
137
140
  if (state.kind !== 'running') {
141
+ if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
142
+ throw new Error('server: legacy pid record requires the matching previous version to stop it');
143
+ }
138
144
  removePidFileIfMatches(config.pidFile, state.snapshot);
139
145
  process.stdout.write('server is not running (removed stale pid file)\n');
140
146
  return;
@@ -170,6 +176,10 @@ export function status(): void {
170
176
  const state = readProcessState(config.pidFile);
171
177
 
172
178
  if (state.kind !== 'running') {
179
+ if (state.kind === 'legacy-json' || state.kind === 'legacy-pid-only' || state.kind === 'legacy-unknown') {
180
+ process.stdout.write('server: legacy pid record requires the matching previous version to stop it\n');
181
+ return;
182
+ }
173
183
  if (state.kind !== 'missing') removePidFileIfMatches(config.pidFile, state.snapshot);
174
184
  process.stdout.write('server: stopped\n');
175
185
  return;
@@ -2,22 +2,37 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
 
5
- export type PidRecord = {
6
- version: 1;
5
+ export type ProcessStartTime = number;
6
+
7
+ export type ProcessIdentity = Readonly<{
7
8
  pid: number;
8
- startTime: string;
9
+ startTime: ProcessStartTime;
10
+ }>;
11
+
12
+ export type ProcessIdentityState = 'alive' | 'dead' | 'unknown';
13
+ export type ProcessIdentityProbe = (identity: ProcessIdentity) => ProcessIdentityState;
14
+
15
+ export type PidRecord = ProcessIdentity & {
16
+ version: 2;
9
17
  };
10
18
 
11
19
  export type ProcessStartTimeQuery = { command: string; args: string[] };
12
20
 
13
21
  export type ProcessState =
14
22
  | { kind: 'missing'; snapshot: null }
15
- | { kind: 'invalid'; snapshot: string }
16
- | { kind: 'legacy'; snapshot: string; pid: number }
23
+ | { kind: 'legacy-json'; snapshot: string; legacy: LegacyProcessSnapshot }
24
+ | { kind: 'legacy-pid-only'; snapshot: string; legacy: LegacyProcessSnapshot }
25
+ | { kind: 'legacy-unknown'; snapshot: string; legacy: LegacyProcessSnapshot; reason: string }
17
26
  | { kind: 'stale'; snapshot: string; record: PidRecord }
18
27
  | { kind: 'running'; snapshot: string; record: PidRecord };
19
28
 
20
- export type LinuxProcessStat = { state: string; startTime: string };
29
+ export type LegacyProcessSnapshot = Readonly<{
30
+ pid?: number;
31
+ raw: string;
32
+ format: 'json-v1' | 'pid-only' | 'malformed';
33
+ }>;
34
+
35
+ export type LinuxProcessStat = { state: string; startTime: number };
21
36
 
22
37
  export function parseLinuxProcessStat(raw: string): LinuxProcessStat | null {
23
38
  const closeParen = raw.lastIndexOf(')');
@@ -28,7 +43,9 @@ export function parseLinuxProcessStat(raw: string): LinuxProcessStat | null {
28
43
  if (typeof state !== 'string' || state.length !== 1 || !/^\d+$/.test(startTime ?? '')) {
29
44
  return null;
30
45
  }
31
- return { state, startTime: startTime! };
46
+ const numericStartTime = Number(startTime);
47
+ if (!Number.isSafeInteger(numericStartTime)) return null;
48
+ return { state, startTime: numericStartTime };
32
49
  }
33
50
 
34
51
  export function buildProcessStartTimeQuery(
@@ -68,10 +85,22 @@ export function isProcessAlive(pid: number, platform: NodeJS.Platform = process.
68
85
  }
69
86
  }
70
87
 
88
+ function parseDarwinStartTime(output: string): number | null {
89
+ const match = output.trim().match(/^(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) ([A-Z][a-z]{2}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/);
90
+ if (!match) return null;
91
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
92
+ const month = months.indexOf(match[1]!);
93
+ if (month < 0) return null;
94
+ const timestamp = Date.UTC(
95
+ Number(match[6]), month, Number(match[2]), Number(match[3]), Number(match[4]), Number(match[5])
96
+ );
97
+ return Number.isNaN(timestamp) ? null : timestamp;
98
+ }
99
+
71
100
  export function getProcessStartTime(
72
101
  pid: number,
73
102
  platform: NodeJS.Platform = process.platform
74
- ): string | null {
103
+ ): ProcessStartTime | null {
75
104
  if (!Number.isInteger(pid) || pid <= 0) return null;
76
105
  if (platform === 'linux') {
77
106
  try {
@@ -85,43 +114,115 @@ export function getProcessStartTime(
85
114
  const query = buildProcessStartTimeQuery(pid, platform);
86
115
  if (query === null) return null;
87
116
  try {
88
- const output = execFileSync(query.command, query.args, { encoding: 'utf8' }).trim();
117
+ const output = execFileSync(query.command, query.args, {
118
+ encoding: 'utf8',
119
+ env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
120
+ }).trim();
89
121
  if (output.length === 0) return null;
90
122
  if (platform === 'win32') {
91
123
  const timestamp = new Date(output);
92
- return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
124
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.getTime();
93
125
  }
94
- return output;
126
+ if (platform === 'darwin') return parseDarwinStartTime(output);
127
+ return null;
95
128
  } catch {
96
129
  return null;
97
130
  }
98
131
  }
99
132
 
100
- function parsePidRecord(raw: string): { kind: 'invalid' } | { kind: 'legacy'; pid: number } | { kind: 'record'; record: PidRecord } {
133
+ export function getProcessIdentityState(
134
+ identity: ProcessIdentity,
135
+ platform: NodeJS.Platform = process.platform
136
+ ): ProcessIdentityState {
137
+ if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
138
+ return 'dead';
139
+ }
140
+ if (platform === 'linux') {
141
+ try {
142
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${identity.pid}/stat`, 'utf8'));
143
+ if (stat === null) return 'unknown';
144
+ if (stat.state === 'Z') return 'dead';
145
+ return stat.startTime === identity.startTime ? 'alive' : 'dead';
146
+ } catch (error) {
147
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'dead' : 'unknown';
148
+ }
149
+ }
150
+ try {
151
+ process.kill(identity.pid, 0);
152
+ } catch (error) {
153
+ const code = (error as NodeJS.ErrnoException).code;
154
+ if (code === 'ESRCH') return 'dead';
155
+ if (code !== 'EPERM') return 'unknown';
156
+ }
157
+ const query = buildProcessStartTimeQuery(identity.pid, platform);
158
+ if (query === null) return 'unknown';
159
+ try {
160
+ const output = execFileSync(query.command, query.args, {
161
+ encoding: 'utf8',
162
+ env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
163
+ }).trim();
164
+ if (output.length === 0) return 'unknown';
165
+ const startTime = platform === 'win32'
166
+ ? new Date(output).getTime()
167
+ : platform === 'darwin'
168
+ ? parseDarwinStartTime(output)
169
+ : null;
170
+ if (startTime === null || Number.isNaN(startTime)) return 'unknown';
171
+ return startTime === identity.startTime ? 'alive' : 'dead';
172
+ } catch {
173
+ return 'unknown';
174
+ }
175
+ }
176
+
177
+ function parsePidRecord(
178
+ raw: string
179
+ ):
180
+ | { kind: 'legacy-json'; legacy: LegacyProcessSnapshot }
181
+ | { kind: 'legacy-pid-only'; legacy: LegacyProcessSnapshot }
182
+ | { kind: 'legacy-unknown'; legacy: LegacyProcessSnapshot; reason: string }
183
+ | { kind: 'record'; record: PidRecord } {
101
184
  const trimmed = raw.trim();
102
185
  if (/^[1-9]\d*$/.test(trimmed)) {
103
186
  const pid = Number(trimmed);
104
- return Number.isSafeInteger(pid) ? { kind: 'legacy', pid } : { kind: 'invalid' };
187
+ return Number.isSafeInteger(pid)
188
+ ? { kind: 'legacy-pid-only', legacy: { pid, raw, format: 'pid-only' } }
189
+ : { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'pid is not a safe integer' };
105
190
  }
106
191
  try {
107
- const value = JSON.parse(trimmed) as Partial<PidRecord> | null;
192
+ const value = JSON.parse(trimmed) as { version?: unknown; pid?: unknown; startTime?: unknown } | null;
193
+ const pidValue: unknown = value?.pid;
194
+ const startTimeValue: unknown = value?.startTime;
108
195
  if (
109
196
  value !== null &&
110
- value.version === 1 &&
111
- Number.isSafeInteger(value.pid) &&
112
- (value.pid ?? 0) > 0 &&
113
- typeof value.startTime === 'string' &&
114
- value.startTime.length > 0
197
+ value.version === 2 &&
198
+ typeof pidValue === 'number' &&
199
+ Number.isSafeInteger(pidValue) &&
200
+ pidValue > 0 &&
201
+ typeof startTimeValue === 'number' &&
202
+ Number.isSafeInteger(startTimeValue) &&
203
+ startTimeValue >= 0
115
204
  ) {
205
+ const startTime = startTimeValue as number;
116
206
  return {
117
207
  kind: 'record',
118
- record: { version: 1, pid: value.pid!, startTime: value.startTime }
208
+ record: { version: 2, pid: pidValue, startTime }
119
209
  };
120
210
  }
211
+ if (
212
+ value !== null &&
213
+ value.version === 1 &&
214
+ typeof pidValue === 'number' &&
215
+ Number.isSafeInteger(pidValue) &&
216
+ pidValue > 0 &&
217
+ typeof value.startTime === 'string' &&
218
+ value.startTime.length > 0
219
+ ) {
220
+ return { kind: 'legacy-json', legacy: { pid: pidValue, raw, format: 'json-v1' } };
221
+ }
222
+ return { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'unsupported PID record schema' };
121
223
  } catch {
122
- // Invalid JSON is an invalid PID record.
224
+ return { kind: 'legacy-unknown', legacy: { raw, format: 'malformed' }, reason: 'invalid PID record JSON' };
123
225
  }
124
- return { kind: 'invalid' };
125
226
  }
126
227
 
127
228
  export function readProcessState(pidFile: string): ProcessState {
@@ -130,12 +231,13 @@ export function readProcessState(pidFile: string): ProcessState {
130
231
  snapshot = fs.readFileSync(pidFile, 'utf8');
131
232
  } catch (error) {
132
233
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing', snapshot: null };
133
- return { kind: 'invalid', snapshot: '' };
234
+ return { kind: 'legacy-unknown', snapshot: '', legacy: { raw: '', format: 'malformed' }, reason: 'PID record cannot be read' };
134
235
  }
135
236
 
136
237
  const parsed = parsePidRecord(snapshot);
137
- if (parsed.kind === 'invalid') return { kind: 'invalid', snapshot };
138
- if (parsed.kind === 'legacy') return { kind: 'legacy', snapshot, pid: parsed.pid };
238
+ if (parsed.kind === 'legacy-json') return { kind: parsed.kind, snapshot, legacy: parsed.legacy };
239
+ if (parsed.kind === 'legacy-pid-only') return { kind: parsed.kind, snapshot, legacy: parsed.legacy };
240
+ if (parsed.kind === 'legacy-unknown') return { kind: parsed.kind, snapshot, legacy: parsed.legacy, reason: parsed.reason };
139
241
 
140
242
  const currentStartTime = getProcessStartTime(parsed.record.pid);
141
243
  if (currentStartTime === null || currentStartTime !== parsed.record.startTime) {
@@ -178,6 +280,6 @@ export function removePidRecordIfMatches(pidFile: string, expected: PidRecord):
178
280
  return removePidFileIfMatches(pidFile, state.snapshot);
179
281
  }
180
282
 
181
- export function processIdentityMatches(record: PidRecord): boolean {
182
- return getProcessStartTime(record.pid) === record.startTime;
283
+ export function processIdentityMatches(identity: ProcessIdentity): boolean {
284
+ return getProcessIdentityState(identity) === 'alive';
183
285
  }
@@ -14,6 +14,7 @@ import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-
14
14
  import type { TaskExecutionLockOptions } from './task-execution-lock.ts';
15
15
  import { captureTaskWriteMetadata, writeTask } from './write.ts';
16
16
  import type { TaskMutation, TaskOperationSummary, TaskWriteOptions } from './write.ts';
17
+ import { allowsManualOverride } from './guard-override.ts';
17
18
 
18
19
  type PrReviewVerdict = 'approved' | 'changes-requested' | 'commented';
19
20
  type PrReviewOutcome = 'aborted' | 'superseded';
@@ -95,7 +96,7 @@ export type PrReviewInspectionResult = {
95
96
  error: { code: string; message: string } | null;
96
97
  };
97
98
 
98
- type ActivityIntentOptions = TaskWriteOptions & { lockOptions?: TaskExecutionLockOptions };
99
+ type ActivityIntentOptions = TaskWriteOptions & { lockOptions?: TaskExecutionLockOptions; lockAlreadyHeld?: boolean };
99
100
  type ArtifactRecord = { identity: ArtifactIdentity; head: string; status: PrReviewArtifactStatus };
100
101
  type RoundState = ArtifactRecord & {
101
102
  startedAgent: string | null;
@@ -397,9 +398,10 @@ export function applyPrReviewActivityIntent(intent: PrReviewActivityIntent, opti
397
398
  if ('code' in validated) return failed(intent, validated.code, validated.message);
398
399
  const resolved = resolveTaskRef(intent.taskRef, { repoRoot: options.repoRoot });
399
400
  if (!resolved.ok) return failed(intent, resolved.code, resolved.message, resolved.taskId);
400
- if (resolved.state !== 'active') {
401
+ if (resolved.state !== 'active' && !allowsManualOverride(options.manualOverride, 'activity-intent', 'TASK_STATE_MISMATCH')) {
401
402
  return failed(intent, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
402
403
  }
404
+ if (options.lockAlreadyHeld) return applyLocked(intent, validated.agent, { ...options, repoRoot: resolved.repoRoot });
403
405
  try {
404
406
  return withTaskExecutionLock(
405
407
  resolved.repoRoot,
@@ -7,6 +7,7 @@ import { locateActivityLog } from './activity-log.ts';
7
7
  import { parseImplementationInputs, selectPendingImplementationInput } from './implementation-inputs.ts';
8
8
  import { parseVerdict } from './review-artifacts.ts';
9
9
  import { extractSection, findSectionHeading } from './sections.ts';
10
+ import { receiptForOutput, sha256File } from './artifact-receipts.ts';
10
11
 
11
12
  const artifactFamilyCatalog = [
12
13
  { family: 'analysis', sectionAliases: ['分析', 'Analysis'], heading: '分析', labels: ['需求分析报告', 'Requirements Analysis'] },
@@ -218,11 +219,7 @@ function resolveReviewedInput(
218
219
  diagnostics.push(diagnostic('BROKEN_REFERENCE', review.family, review.name, String(error)));
219
220
  return null;
220
221
  }
221
- const lines = content.split(/\r?\n/);
222
- const header = lines.findIndex((line) => /\*\*(?:审查输入|Review Input)\*\*[::]/.test(line));
223
- const referenceBlock = header >= 0 ? lines.slice(header, header + 12).join('\n') : '';
224
- const candidates = [...referenceBlock.matchAll(/`([^`]+\.md)`/g)].map((match) => match[1]!);
225
- const parsed = candidates.map((name) => parseArtifactName(name)).find((item) => item?.family === expectedFamily);
222
+ const parsed = parseReviewedInputReference(content, expectedFamily);
226
223
  if (!parsed) {
227
224
  diagnostics.push(diagnostic('BROKEN_REFERENCE', review.family, review.name, `${review.name} does not reference a canonical ${expectedFamily} artifact`));
228
225
  return null;
@@ -231,9 +228,16 @@ function resolveReviewedInput(
231
228
  try {
232
229
  const stat = fs.lstatSync(abs);
233
230
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('referenced input is not a regular file');
234
- if (stat.mtimeMs > review.mtimeMs) {
235
- diagnostics.push(diagnostic('BROKEN_REFERENCE', review.family, review.name, `${parsed.name} is newer than ${review.name}`));
236
- return null;
231
+ const taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8');
232
+ const receipt = receiptForOutput(taskContent, review.name);
233
+ if (!receipt) throw new Error(`receipt for ${review.name} is missing`);
234
+ const expectedEvent = reviewEventName(review.family);
235
+ if (receipt.event !== expectedEvent || receipt.input !== parsed.name) {
236
+ throw new Error(`receipt for ${review.name} does not match ${parsed.name}`);
237
+ }
238
+ const actualSha256 = sha256File(abs);
239
+ if (actualSha256 !== receipt.inputSha256) {
240
+ throw new Error(`${parsed.name} content digest does not match receipt`);
237
241
  }
238
242
  return { ...parsed, path: abs, size: stat.size, mtimeMs: stat.mtimeMs };
239
243
  } catch (error) {
@@ -242,6 +246,54 @@ function resolveReviewedInput(
242
246
  }
243
247
  }
244
248
 
249
+ function parseReviewedInputReference(content: string, expectedFamily: ArtifactFamily): { family: ArtifactFamily; round: number; name: string } | null {
250
+ const lines = content.split(/\r?\n/);
251
+ const header = lines.findIndex((line) => /\*\*(?:审查输入|Review Input)\*\*[::]/.test(line));
252
+ const referenceBlock = header >= 0 ? lines.slice(header, header + 12).join('\n') : '';
253
+ const candidates = [...referenceBlock.matchAll(/`([^`]+\.md)`/g)].map((match) => match[1]!);
254
+ return candidates
255
+ .map((name) => parseArtifactName(name))
256
+ .find((item) => item?.family === expectedFamily) ?? null;
257
+ }
258
+
259
+ function parseCodePlanInputReference(content: string): { family: 'plan'; round: number; name: string } | null {
260
+ const lines = content.split(/\r?\n/);
261
+ const sectionStart = lines.findIndex((line) => /^##\s+(?:实现输入|Implementation Input)\s*$/.test(line.trim()));
262
+ if (sectionStart < 0) return null;
263
+ const bodyLines = lines.slice(sectionStart + 1);
264
+ const nextSection = bodyLines.findIndex((line) => /^##\s+/.test(line.trim()));
265
+ const body = (nextSection < 0 ? bodyLines : bodyLines.slice(0, nextSection)).join('\n');
266
+ const match = body.match(/\*\*(?:方案输入|Plan Input)\*\*[::]\s*`([^`]+\.md)`/);
267
+ if (!match) return null;
268
+ const parsed = parseArtifactName(match[1]!);
269
+ return parsed?.family === 'plan' ? { ...parsed, family: 'plan' } : null;
270
+ }
271
+
272
+ function reviewEventName(family: ArtifactFamily): 'review-analysis.completed' | 'review-plan.completed' | 'review-code.completed' {
273
+ if (family === 'review-analysis') return 'review-analysis.completed';
274
+ if (family === 'review-plan') return 'review-plan.completed';
275
+ if (family === 'review-code') return 'review-code.completed';
276
+ throw new Error(`unsupported review family '${family}'`);
277
+ }
278
+
279
+ function resolveCodeInputReceipt(taskDir: string, code: ArtifactIdentity): { input: ArtifactIdentity; inputSha256: string } | null {
280
+ let taskContent: string;
281
+ try { taskContent = fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'); }
282
+ catch { return null; }
283
+ let receipt;
284
+ try { receipt = receiptForOutput(taskContent, code.name); }
285
+ catch { return null; }
286
+ if (!receipt || receipt.event !== 'code.completed') return null;
287
+ const plan = inspectArtifactDirectory(taskDir, 'plan');
288
+ if (plan.status !== 'ready') return null;
289
+ const input = plan.artifacts.find((artifact) => artifact.name === receipt.input);
290
+ return input ? { input, inputSha256: receipt.inputSha256 } : null;
291
+ }
292
+
293
+ function resolveCodePlanInput(taskDir: string, code: ArtifactIdentity): { input: ArtifactIdentity; inputSha256: string } | null {
294
+ return resolveCodeInputReceipt(taskDir, code);
295
+ }
296
+
245
297
  function assertWritableInventory(inventory: ArtifactInventoryResult): ArtifactError | null {
246
298
  if (inventory.status === 'failed') return inventory.error;
247
299
  const blockers = inventory.diagnostics.filter((item) => BLOCKING_DIAGNOSTICS.has(item.code));
@@ -312,11 +364,21 @@ function resolveCodeContext(inventory: ArtifactInventoryResult, options: Inspect
312
364
  return withCodeMode(inventory, inputs, 'ready', 'init', codeMax, reviewMax, null, null,
313
365
  'No prior code artifact. Starting initial implementation (round 1 -> code.md).');
314
366
  }
367
+ const codePlanInput = resolveCodePlanInput(inventory.taskDir!, latestCode);
368
+ if (!codePlanInput) {
369
+ return contextFailure(inventory, 'ARTIFACT_REFERENCE_INVALID', `code completion receipt for ${latestCode.name} is missing or does not match the current plan`, latestCode.name);
370
+ }
371
+ if (reviewPlan.latest && !reviewPlan.reviewedInput) {
372
+ return contextFailure(inventory, 'ARTIFACT_REFERENCE_INVALID', `${reviewPlan.latest.name} has no valid reviewed input`, reviewPlan.latest.name);
373
+ }
315
374
  if (reviewPlan.latest && reviewPlan.reviewedInput?.name === plan.latest.name) {
316
375
  const verdict = parseVerdict(reviewPlan.latest.path);
317
- if (verdict.ok && verdict.verdict === 'Approved' && reviewPlan.latest.mtimeMs > latestCode.mtimeMs) {
376
+ const reviewedPlanSha256 = sha256File(reviewPlan.reviewedInput.path);
377
+ if (verdict.ok && verdict.verdict === 'Approved' && (
378
+ codePlanInput.input.name !== plan.latest.name || codePlanInput.inputSha256 !== reviewedPlanSha256
379
+ )) {
318
380
  return withCodeMode(inventory, inputs, 'ready', 'init', codeMax, reviewMax, verdict.verdict, reviewPlan.latest.name,
319
- `Latest ${reviewPlan.latest.name} is approved and newer than the latest code artifact. Entering replan-driven init.`);
381
+ `Latest ${reviewPlan.latest.name} approves plan content not captured by the latest code input receipt. Entering replan-driven init.`);
320
382
  }
321
383
  }
322
384
  if (reviewMax < codeMax) {
@@ -440,6 +502,7 @@ function buildArtifactLinkSection(content: string, artifact: ArtifactIdentity):
440
502
  export {
441
503
  artifactFamilyCatalog, artifactName, parseArtifactName, familySpec,
442
504
  inspectTaskArtifacts, inspectArtifactDirectory, resolveArtifactContext,
505
+ parseReviewedInputReference, parseCodePlanInputReference, resolveCodePlanInput, reviewEventName,
443
506
  assertWritableInventory, validateCompletedArtifact, buildArtifactLinkSection
444
507
  };
445
508
  export type {
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ import { extractSection, findSectionHeading, parseTable } from './sections.ts';
5
+
6
+ const RECEIPT_SECTION_ALIASES = ['产物生命周期收据', 'Artifact Lifecycle Receipts'] as const;
7
+ const RECEIPT_COLUMNS = ['event', 'output', 'input', 'input_sha256', 'completed_at'] as const;
8
+ const RECEIPT_EVENTS = new Set([
9
+ 'review-analysis.completed',
10
+ 'review-plan.completed',
11
+ 'review-code.completed',
12
+ 'code.completed'
13
+ ]);
14
+ const SHA256_RE = /^[a-f0-9]{64}$/;
15
+ const ARTIFACT_NAME_RE = /^(analysis|review-analysis|plan|review-plan|code|review-code|manual-validation|validation-run|pr-review)(?:-r([2-9]|[1-9]\d+))?\.md$/;
16
+ const RECEIPT_SHAPES = {
17
+ 'review-analysis.completed': { output: 'review-analysis', input: 'analysis' },
18
+ 'review-plan.completed': { output: 'review-plan', input: 'plan' },
19
+ 'review-code.completed': { output: 'review-code', input: 'code' },
20
+ 'code.completed': { output: 'code', input: 'plan' }
21
+ } as const;
22
+
23
+ type ArtifactReceiptEvent =
24
+ | 'review-analysis.completed'
25
+ | 'review-plan.completed'
26
+ | 'review-code.completed'
27
+ | 'code.completed';
28
+ type ArtifactReceipt = {
29
+ event: ArtifactReceiptEvent;
30
+ output: string;
31
+ input: string;
32
+ inputSha256: string;
33
+ completedAt: string;
34
+ };
35
+ type ArtifactReceiptParseResult = {
36
+ present: boolean;
37
+ rows: readonly ArtifactReceipt[];
38
+ };
39
+ type ReceiptSectionMutation = {
40
+ aliases: readonly string[];
41
+ heading: string;
42
+ body: string;
43
+ };
44
+
45
+ class ArtifactReceiptError extends Error {
46
+ readonly code = 'ARTIFACT_RECEIPT_INVALID';
47
+
48
+ constructor(message: string) {
49
+ super(message);
50
+ this.name = 'ArtifactReceiptError';
51
+ }
52
+ }
53
+
54
+ function sha256Bytes(value: Buffer): string {
55
+ return createHash('sha256').update(value).digest('hex');
56
+ }
57
+
58
+ function sha256File(filePath: string): string {
59
+ return sha256Bytes(fs.readFileSync(filePath));
60
+ }
61
+
62
+ function parseReceiptArtifactName(name: string): { family: string } | null {
63
+ const match = ARTIFACT_NAME_RE.exec(name);
64
+ if (!match) return null;
65
+ const roundText = match[2];
66
+ if (roundText !== undefined) {
67
+ const round = Number(roundText);
68
+ if (!Number.isSafeInteger(round) || String(round) !== roundText) return null;
69
+ }
70
+ return { family: match[1]! };
71
+ }
72
+
73
+ function validateReceiptShape(event: ArtifactReceiptEvent, output: string, input: string): void {
74
+ const outputIdentity = parseReceiptArtifactName(output);
75
+ const inputIdentity = parseReceiptArtifactName(input);
76
+ if (!outputIdentity || !inputIdentity) throw new ArtifactReceiptError(`receipt artifact identity is invalid: ${output} -> ${input}`);
77
+ const shape = RECEIPT_SHAPES[event];
78
+ if (outputIdentity.family !== shape.output || inputIdentity.family !== shape.input) {
79
+ throw new ArtifactReceiptError(`receipt event '${event}' does not match ${output} -> ${input}`);
80
+ }
81
+ }
82
+
83
+ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
84
+ const section = extractSection(content, [...RECEIPT_SECTION_ALIASES]);
85
+ if (!section) return { present: false, rows: [] };
86
+
87
+ let table;
88
+ try {
89
+ table = parseTable(content, {
90
+ sectionAliases: [...RECEIPT_SECTION_ALIASES],
91
+ columns: [...RECEIPT_COLUMNS],
92
+ keyColumn: 'output'
93
+ });
94
+ } catch (error) {
95
+ throw new ArtifactReceiptError(error instanceof Error ? error.message : String(error));
96
+ }
97
+ if (!table) throw new ArtifactReceiptError('receipt section has no receipt table');
98
+
99
+ const rows = table.rows.map((row) => {
100
+ const event = row.values.event ?? '';
101
+ const output = row.values.output ?? '';
102
+ const input = row.values.input ?? '';
103
+ const inputSha256 = row.values.input_sha256 ?? '';
104
+ const completedAt = row.values.completed_at ?? '';
105
+ if (!RECEIPT_EVENTS.has(event)) throw new ArtifactReceiptError(`unknown receipt event '${event}'`);
106
+ if (!output || !input) throw new ArtifactReceiptError('receipt output and input are required');
107
+ validateReceiptShape(event as ArtifactReceiptEvent, output, input);
108
+ if (!SHA256_RE.test(inputSha256)) throw new ArtifactReceiptError(`receipt digest for '${output}' is invalid`);
109
+ if (!completedAt || Number.isNaN(Date.parse(completedAt.replace(' ', 'T')))) {
110
+ throw new ArtifactReceiptError(`receipt completion time for '${output}' is invalid`);
111
+ }
112
+ return {
113
+ event: event as ArtifactReceiptEvent,
114
+ output,
115
+ input,
116
+ inputSha256,
117
+ completedAt
118
+ };
119
+ });
120
+ return { present: true, rows };
121
+ }
122
+
123
+ function receiptForOutput(content: string, output: string): ArtifactReceipt | null {
124
+ const parsed = parseArtifactReceipts(content);
125
+ return parsed.rows.find((row) => row.output === output) ?? null;
126
+ }
127
+
128
+ function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): ReceiptSectionMutation {
129
+ if (!RECEIPT_EVENTS.has(receipt.event)) throw new ArtifactReceiptError(`unknown receipt event '${receipt.event}'`);
130
+ if (!receipt.output || !receipt.input) throw new ArtifactReceiptError('receipt output and input are required');
131
+ validateReceiptShape(receipt.event, receipt.output, receipt.input);
132
+ if (!SHA256_RE.test(receipt.inputSha256)) throw new ArtifactReceiptError(`receipt digest for '${receipt.output}' is invalid`);
133
+ if (!receipt.completedAt || Number.isNaN(Date.parse(receipt.completedAt.replace(' ', 'T')))) {
134
+ throw new ArtifactReceiptError(`receipt completion time for '${receipt.output}' is invalid`);
135
+ }
136
+
137
+ const existing = parseArtifactReceipts(content).rows;
138
+ const previous = existing.find((row) => row.output === receipt.output);
139
+ if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) {
140
+ throw new ArtifactReceiptError(`receipt for '${receipt.output}' already exists with different evidence`);
141
+ }
142
+ const rows = previous ? existing : [...existing, receipt];
143
+ const heading = findSectionHeading(content, [...RECEIPT_SECTION_ALIASES]);
144
+ const body = [
145
+ `| ${RECEIPT_COLUMNS.join(' | ')} |`,
146
+ `| ${RECEIPT_COLUMNS.map(() => '---').join(' | ')} |`,
147
+ ...rows.map((row) => `| ${row.event} | ${row.output} | ${row.input} | ${row.inputSha256} | ${row.completedAt} |`)
148
+ ].join('\n');
149
+ return { aliases: [...RECEIPT_SECTION_ALIASES], heading, body };
150
+ }
151
+
152
+ export {
153
+ ArtifactReceiptError,
154
+ RECEIPT_SECTION_ALIASES,
155
+ parseArtifactReceipts,
156
+ receiptForOutput,
157
+ sha256Bytes,
158
+ sha256File,
159
+ upsertArtifactReceipt
160
+ };
161
+ export type { ArtifactReceipt, ArtifactReceiptEvent, ArtifactReceiptParseResult, ReceiptSectionMutation };