@fitlab-ai/agent-infra 0.9.4 → 0.9.5

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 (56) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +10 -0
  4. package/dist/lib/agent-clients/adapter.js +2 -2
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +382 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +163 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +211 -0
  8. package/dist/lib/agent-clients/adapters/codex.js +2 -2
  9. package/dist/lib/git/worktree-identity.js +64 -0
  10. package/dist/lib/internal/codex-lifecycle.js +202 -0
  11. package/dist/lib/internal/task-create.js +48 -0
  12. package/dist/lib/internal/task-orchestration.js +68 -29
  13. package/dist/lib/sandbox/commands/create.js +1 -0
  14. package/dist/lib/sandbox/control/client.js +29 -15
  15. package/dist/lib/sandbox/control/protocol.js +23 -4
  16. package/dist/lib/sandbox/control/server.js +53 -18
  17. package/dist/lib/sandbox/workspace-view.js +8 -2
  18. package/dist/lib/task/activity-log.js +32 -5
  19. package/dist/lib/task/commit-finalization.js +14 -3
  20. package/dist/lib/task/create-service.js +137 -0
  21. package/dist/lib/task/create.js +333 -0
  22. package/dist/lib/task/orchestration.js +151 -15
  23. package/dist/lib/task/review-fingerprint.js +4 -0
  24. package/dist/lib/task/workspace-snapshot.js +78 -3
  25. package/lib/agent-clients/adapter.ts +4 -4
  26. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +442 -0
  27. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +307 -0
  28. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +250 -0
  29. package/lib/agent-clients/adapters/codex.ts +2 -2
  30. package/lib/git/worktree-identity.ts +86 -0
  31. package/lib/internal/codex-lifecycle.ts +204 -0
  32. package/lib/internal/task-create.ts +49 -0
  33. package/lib/internal/task-orchestration.ts +54 -28
  34. package/lib/sandbox/commands/create.ts +1 -0
  35. package/lib/sandbox/control/client.ts +42 -18
  36. package/lib/sandbox/control/protocol.ts +43 -8
  37. package/lib/sandbox/control/server.ts +60 -22
  38. package/lib/sandbox/workspace-view.ts +9 -2
  39. package/lib/task/activity-log.ts +46 -7
  40. package/lib/task/commit-finalization.ts +23 -3
  41. package/lib/task/create-service.ts +171 -0
  42. package/lib/task/create.ts +372 -0
  43. package/lib/task/orchestration.ts +182 -18
  44. package/lib/task/review-fingerprint.ts +9 -0
  45. package/lib/task/workspace-snapshot.ts +98 -4
  46. package/package.json +7 -7
  47. package/templates/.agents/hooks/lifecycle-delegation.js +47 -3
  48. package/templates/.agents/rules/task-management.en.md +1 -0
  49. package/templates/.agents/rules/task-management.zh-CN.md +1 -0
  50. package/templates/.agents/skills/commit/SKILL.en.md +3 -9
  51. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
  52. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
  53. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
  54. package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
  55. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
  56. package/templates/.codex/hooks.json +50 -0
@@ -36,6 +36,8 @@ import {
36
36
  diffWorkspaceSnapshots
37
37
  } from './workspace-snapshot.ts';
38
38
  import type { RepositorySnapshot } from './workspace-snapshot.ts';
39
+ import type { WorkspaceSnapshotContext } from './workspace-snapshot.ts';
40
+ import { assertGitRepositoryBinding } from '../git/worktree-identity.ts';
39
41
  import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
40
42
  import {
41
43
  CommitIntentError,
@@ -55,6 +57,12 @@ import {
55
57
  } from './commit-finalization.ts';
56
58
  import type { CommitFinalizationInspection } from './commit-finalization.ts';
57
59
  import { captureTaskWriteMetadata, writeTask } from './write.ts';
60
+ import {
61
+ appendActivityEntry,
62
+ commitAttemptStartedNote,
63
+ locateActivityLog,
64
+ pairEntries
65
+ } from './activity-log.ts';
58
66
 
59
67
  type OrchestrationStatus = 'running' | 'paused' | 'completed';
60
68
  type LegacyOrchestrationModelPolicy = Readonly<{
@@ -147,12 +155,13 @@ type OrchestrationCompletionPlanResult = Readonly<{
147
155
  }>;
148
156
  type OrchestrationOptions = {
149
157
  repoRoot?: string;
158
+ gitWorktreeRoot?: string;
150
159
  id?: () => string;
151
160
  now?: () => string;
152
161
  maxSteps?: number;
153
162
  client?: AgentClientId;
154
163
  modelPolicy?: OrchestrationModelPolicy;
155
- captureWorkspace?: (repoRoot: string, taskId: string | null) => string;
164
+ captureWorkspace?: (context: WorkspaceSnapshotContext) => string;
156
165
  captureRepository?: (repoRoot: string) => RepositorySnapshot;
157
166
  inspectPullRequest?: (taskId: string, options: { cwd: string }) => Readonly<{
158
167
  status: string;
@@ -188,6 +197,7 @@ type CommitIntentResult = Readonly<{
188
197
  committedHead: string | null;
189
198
  needsAnchor: boolean;
190
199
  needsLog: boolean;
200
+ attempt: Readonly<{ attempt: string; baseline: string; agent: string }> | null;
191
201
  }>;
192
202
  token?: string;
193
203
  error: Readonly<{ code: string; message: string }> | null;
@@ -259,6 +269,11 @@ function repositoryHead(repoRoot: string): string {
259
269
  }).trim();
260
270
  }
261
271
 
272
+ function gitRootFor(stateRoot: string, options: OrchestrationOptions): string {
273
+ if (!options.gitWorktreeRoot) return stateRoot;
274
+ return assertGitRepositoryBinding(stateRoot, options.gitWorktreeRoot).worktreeRoot;
275
+ }
276
+
262
277
  function isAncestor(repoRoot: string, ancestor: string, descendant: string): boolean {
263
278
  return spawnSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], {
264
279
  cwd: repoRoot,
@@ -287,7 +302,8 @@ function commitFinalizationView(inspection: CommitFinalizationInspection): NonNu
287
302
  currentHead: inspection.currentHead,
288
303
  committedHead: inspection.committedHead,
289
304
  needsAnchor: inspection.needsAnchor,
290
- needsLog: inspection.needsLog
305
+ needsLog: inspection.needsLog,
306
+ attempt: inspection.attempt
291
307
  };
292
308
  }
293
309
 
@@ -607,6 +623,12 @@ function routeFromFacts(taskDir: string): Omit<OrchestrationNext, 'requestedMode
607
623
  function routeOrchestration(taskRef: string, options: OrchestrationOptions = {}): OrchestrationResult {
608
624
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
609
625
  if (!resolved.ok) return failed(resolved.code, resolved.message, resolved.taskId);
626
+ let gitRoot: string;
627
+ try {
628
+ gitRoot = gitRootFor(resolved.repoRoot, options);
629
+ } catch (error) {
630
+ return failed('ORCHESTRATION_WORKTREE_INVALID', error instanceof Error ? error.message : String(error), resolved.taskId);
631
+ }
610
632
  let content: string;
611
633
  try {
612
634
  content = fs.readFileSync(resolved.taskMdPath, 'utf8');
@@ -677,7 +699,7 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
677
699
  const capture = options.captureRepository ?? captureRepositorySnapshot;
678
700
  let before: RepositorySnapshot;
679
701
  try {
680
- before = capture(resolved.repoRoot);
702
+ before = capture(gitRoot);
681
703
  } catch (error) {
682
704
  return failed('ORCHESTRATION_SNAPSHOT_FAILED', error instanceof Error ? error.message : String(error), resolved.taskId);
683
705
  }
@@ -693,7 +715,7 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
693
715
  options.inspectPullRequest ?? inspectPlatformPullRequest;
694
716
  let inspection: ReturnType<NonNullable<OrchestrationOptions['inspectPullRequest']>>;
695
717
  try {
696
- inspection = inspect(resolved.taskId, { cwd: resolved.repoRoot });
718
+ inspection = inspect(resolved.taskId, { cwd: gitRoot });
697
719
  } catch (error) {
698
720
  return failed('ORCHESTRATION_PR_INSPECTION_FAILED', error instanceof Error ? error.message : String(error), resolved.taskId);
699
721
  }
@@ -714,7 +736,7 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
714
736
 
715
737
  let after: RepositorySnapshot;
716
738
  try {
717
- after = capture(resolved.repoRoot);
739
+ after = capture(gitRoot);
718
740
  } catch (error) {
719
741
  return failed('ORCHESTRATION_SNAPSHOT_FAILED', error instanceof Error ? error.message : String(error), resolved.taskId);
720
742
  }
@@ -748,16 +770,119 @@ function routeOrchestration(taskRef: string, options: OrchestrationOptions = {})
748
770
  return { status: 'running', changed: false, taskId: resolved.taskId, run, next, error: null };
749
771
  }
750
772
 
773
+ function appendCommitActivity(
774
+ resolved: Extract<ReturnType<typeof resolveTaskRef>, { ok: true }>,
775
+ step: string,
776
+ agent: string,
777
+ note: string
778
+ ): Readonly<{ code: string; message: string }> | null {
779
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
780
+ const activity = locateActivityLog(taskContent);
781
+ if (!activity) return { code: 'ORCHESTRATION_TASK_INVALID', message: 'task activity log is missing or ambiguous' };
782
+ const metadata = captureTaskWriteMetadata();
783
+ const written = writeTask({
784
+ taskRef: resolved.taskId,
785
+ expectedState: 'active',
786
+ mutations: [{
787
+ kind: 'section',
788
+ aliases: ['活动日志', 'Activity Log'],
789
+ heading: activity.heading,
790
+ body: appendActivityEntry(activity, { time: metadata.timestamp, step, agent, note })
791
+ }]
792
+ }, { repoRoot: resolved.repoRoot, metadataProvider: () => metadata });
793
+ return written.status === 'failed' ? written.error : null;
794
+ }
795
+
796
+ function startCommitAttempt(
797
+ taskRef: string,
798
+ input: Readonly<{ agent: string }>,
799
+ options: OrchestrationOptions = {}
800
+ ): CommitIntentResult {
801
+ const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
802
+ if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
803
+ try {
804
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
805
+ return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-attempt.start', () => {
806
+ if (fs.existsSync(commitIntentPath(resolved.taskDir))) {
807
+ return commitIntentFailed('ORCHESTRATION_COMMIT_INTENT_BUSY', 'an active commit intent already exists', resolved.taskId);
808
+ }
809
+ const taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
810
+ const activity = locateActivityLog(taskContent);
811
+ if (!activity) return commitIntentFailed('ORCHESTRATION_TASK_INVALID', 'task activity log is missing or ambiguous', resolved.taskId);
812
+ const open = pairEntries(activity.entries).filter((row) => row.step === 'Commit' && row.started !== '' && row.done === '');
813
+ if (open.length > 0) {
814
+ return commitIntentFailed('ORCHESTRATION_COMMIT_ATTEMPT_BUSY', 'an open Commit activity entry already exists', resolved.taskId);
815
+ }
816
+ const currentHead = repositoryHead(gitRoot);
817
+ const attempt = (options.id ?? randomUUID)();
818
+ const error = appendCommitActivity(resolved, 'Commit [started]', input.agent, commitAttemptStartedNote({
819
+ attempt,
820
+ baseline: currentHead,
821
+ agent: input.agent
822
+ }));
823
+ if (error) return commitIntentFailed(error.code, error.message, resolved.taskId);
824
+ const inspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
825
+ return {
826
+ status: 'ready', changed: true, taskId: resolved.taskId, intent: null,
827
+ finalization: commitFinalizationView(inspection), error: null
828
+ };
829
+ });
830
+ } catch (error) {
831
+ return mapCommitIntentError(error, resolved.taskId);
832
+ }
833
+ }
834
+
835
+ function terminateCommitAttempt(
836
+ taskRef: string,
837
+ input: Readonly<{ attempt: string; agent: string; code: string }>,
838
+ options: OrchestrationOptions = {}
839
+ ): CommitIntentResult {
840
+ const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
841
+ if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
842
+ try {
843
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
844
+ return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-attempt.terminate', () => {
845
+ if (fs.existsSync(commitIntentPath(resolved.taskDir))) {
846
+ return commitIntentFailed('ORCHESTRATION_COMMIT_RECOVERY_REQUIRED', 'an active commit intent must be aborted or recovered first', resolved.taskId);
847
+ }
848
+ const inspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
849
+ if (
850
+ inspection.disposition !== 'retryable-start'
851
+ || inspection.attempt?.attempt !== input.attempt
852
+ || inspection.attempt.agent !== input.agent
853
+ || inspection.attempt.baseline !== inspection.currentHead
854
+ ) {
855
+ return commitIntentFailed('ORCHESTRATION_COMMIT_RECOVERY_REQUIRED', 'commit attempt identity or repository HEAD has drifted', resolved.taskId);
856
+ }
857
+ const error = appendCommitActivity(
858
+ resolved,
859
+ 'Commit [aborted]',
860
+ input.agent,
861
+ `aborted; attempt=${input.attempt}; code=${input.code}`
862
+ );
863
+ if (error) return commitIntentFailed(error.code, error.message, resolved.taskId);
864
+ return {
865
+ status: 'ready', changed: true, taskId: resolved.taskId, intent: null,
866
+ finalization: commitFinalizationView(inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId)),
867
+ error: null
868
+ };
869
+ });
870
+ } catch (error) {
871
+ return mapCommitIntentError(error, resolved.taskId);
872
+ }
873
+ }
874
+
751
875
  function beginCommitIntent(
752
876
  taskRef: string,
753
- input: Readonly<{ agent: string; orchestrated: boolean; baselineHead: string }>,
877
+ input: Readonly<{ agent: string; orchestrated: boolean; baselineHead: string; attempt: string }>,
754
878
  options: OrchestrationOptions = {}
755
879
  ): CommitIntentResult {
756
880
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
757
881
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
758
882
  try {
883
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
759
884
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-intent.begin', () => {
760
- const currentHead = repositoryHead(resolved.repoRoot);
885
+ const currentHead = repositoryHead(gitRoot);
761
886
  if (currentHead !== input.baselineHead) {
762
887
  return commitIntentFailed('ORCHESTRATION_COMMIT_BASELINE_MISMATCH', 'baseline HEAD does not match the repository HEAD', resolved.taskId);
763
888
  }
@@ -769,7 +894,19 @@ function beginCommitIntent(
769
894
  }
770
895
  return commitIntentFailed('ORCHESTRATION_COMMIT_INTENT_BUSY', 'an active commit intent already exists', resolved.taskId);
771
896
  }
772
-
897
+ const attemptInspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
898
+ if (
899
+ attemptInspection.disposition !== 'retryable-start'
900
+ || attemptInspection.attempt?.attempt !== input.attempt
901
+ || attemptInspection.attempt.agent !== input.agent
902
+ || attemptInspection.attempt.baseline !== input.baselineHead
903
+ ) {
904
+ return commitIntentFailed(
905
+ 'ORCHESTRATION_COMMIT_ATTEMPT_MISMATCH',
906
+ 'commit begin requires one matching retryable Commit attempt',
907
+ resolved.taskId
908
+ );
909
+ }
773
910
  const runFile = orchestrationPath(resolved.taskDir);
774
911
  let run: OrchestrationRun | null = null;
775
912
  let sourceRunBytes: string | null = null;
@@ -883,13 +1020,14 @@ function checkpointCommitIntent(
883
1020
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
884
1021
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
885
1022
  try {
1023
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
886
1024
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-intent.checkpoint', () => {
887
1025
  const intent = readCommitIntent(resolved.taskDir, resolved.taskId, input.token);
888
- const currentHead = repositoryHead(resolved.repoRoot);
1026
+ const currentHead = repositoryHead(gitRoot);
889
1027
  if (currentHead !== input.head) {
890
1028
  return commitIntentFailed('ORCHESTRATION_COMMIT_RECOVERY_REQUIRED', 'checkpoint HEAD does not match the repository HEAD', resolved.taskId);
891
1029
  }
892
- if (input.kind === 'committed' && !isAncestor(resolved.repoRoot, intent.baselineHead, input.head)) {
1030
+ if (input.kind === 'committed' && !isAncestor(gitRoot, intent.baselineHead, input.head)) {
893
1031
  return commitIntentFailed('ORCHESTRATION_COMMIT_RECOVERY_REQUIRED', 'committed HEAD does not descend from the baseline', resolved.taskId);
894
1032
  }
895
1033
  if (input.kind === 'pushed') {
@@ -1016,9 +1154,10 @@ function completeCommitIntent(
1016
1154
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
1017
1155
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
1018
1156
  try {
1157
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
1019
1158
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-intent.complete', () => {
1020
1159
  readCommitIntent(resolved.taskDir, resolved.taskId, input.token);
1021
- const inspection = inspectCommitFinalization(resolved.taskDir, resolved.repoRoot, resolved.taskId);
1160
+ const inspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
1022
1161
  return finalizeCommitIntentUnlocked(resolved, inspection, input.agent, () => {
1023
1162
  removeCommitIntent(resolved.taskDir, resolved.taskId, input.token);
1024
1163
  });
@@ -1036,8 +1175,15 @@ function recoverCommitIntent(
1036
1175
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
1037
1176
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
1038
1177
  try {
1178
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
1039
1179
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-intent.recover', () => {
1040
- const inspection = inspectCommitFinalization(resolved.taskDir, resolved.repoRoot, resolved.taskId);
1180
+ const inspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
1181
+ if (inspection.disposition === 'retryable-start') {
1182
+ return {
1183
+ status: 'ready', changed: false, taskId: resolved.taskId, intent: null,
1184
+ finalization: commitFinalizationView(inspection), error: null
1185
+ };
1186
+ }
1041
1187
  if (inspection.disposition === 'prepared' && inspection.intentDigest) {
1042
1188
  removeCommitIntentByDigest(resolved.taskDir, resolved.taskId, inspection.intentDigest);
1043
1189
  return { status: 'ready', changed: true, taskId: resolved.taskId, intent: null, error: null };
@@ -1066,9 +1212,10 @@ function abortCommitIntent(
1066
1212
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
1067
1213
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
1068
1214
  try {
1215
+ const gitRoot = gitRootFor(resolved.repoRoot, options);
1069
1216
  return withTaskExecutionLock(resolved.repoRoot, resolved.taskId, 'commit-intent.abort', () => {
1070
1217
  const intent = readCommitIntent(resolved.taskDir, resolved.taskId, input.token);
1071
- const currentHead = repositoryHead(resolved.repoRoot);
1218
+ const currentHead = repositoryHead(gitRoot);
1072
1219
  if (
1073
1220
  intent.phase !== 'prepared'
1074
1221
  || intent.pushEvidence !== null
@@ -1088,7 +1235,13 @@ function abortCommitIntent(
1088
1235
  function statusCommitIntent(taskRef: string, options: OrchestrationOptions = {}): CommitIntentResult {
1089
1236
  const resolved = resolveTaskRef(taskRef, { repoRoot: options.repoRoot });
1090
1237
  if (!resolved.ok) return commitIntentFailed(resolved.code, resolved.message, resolved.taskId);
1091
- const inspection = inspectCommitFinalization(resolved.taskDir, resolved.repoRoot, resolved.taskId);
1238
+ let gitRoot: string;
1239
+ try {
1240
+ gitRoot = gitRootFor(resolved.repoRoot, options);
1241
+ } catch (error) {
1242
+ return commitIntentFailed('ORCHESTRATION_WORKTREE_INVALID', error instanceof Error ? error.message : String(error), resolved.taskId);
1243
+ }
1244
+ const inspection = inspectCommitFinalization(resolved.taskDir, gitRoot, resolved.taskId);
1092
1245
  return {
1093
1246
  status: 'ready',
1094
1247
  changed: false,
@@ -1156,7 +1309,11 @@ function prepareOrchestrationDelegationUnlocked(
1156
1309
  }
1157
1310
  let beforeFingerprint: string;
1158
1311
  try {
1159
- beforeFingerprint = (options.captureWorkspace ?? captureWorkspaceSnapshot)(resolved.repoRoot, resolved.taskId);
1312
+ beforeFingerprint = (options.captureWorkspace ?? captureWorkspaceSnapshot)({
1313
+ gitRoot: gitRootFor(resolved.repoRoot, options),
1314
+ stateRoot: resolved.repoRoot,
1315
+ taskId: resolved.taskId
1316
+ });
1160
1317
  } catch (error) {
1161
1318
  return failed('ORCHESTRATION_SNAPSHOT_FAILED', error instanceof Error ? error.message : String(error), resolved.taskId);
1162
1319
  }
@@ -1280,9 +1437,14 @@ function sealMatchingOrchestrationDelegation(
1280
1437
  }
1281
1438
  const repoRoot = options.repoRoot ?? process.cwd();
1282
1439
  try {
1440
+ const gitRoot = gitRootFor(repoRoot, options);
1283
1441
  const snapshotTaskId = receipt.workspaceSnapshotScope === 'task' ? receipt.taskId : null;
1284
- const afterFingerprint = (options.captureWorkspace ?? captureWorkspaceSnapshot)(repoRoot, snapshotTaskId);
1285
- const changedPaths = (options.diffWorkspace ?? diffWorkspaceSnapshots)(repoRoot, receipt.beforeFingerprint, afterFingerprint);
1442
+ const afterFingerprint = (options.captureWorkspace ?? captureWorkspaceSnapshot)({
1443
+ gitRoot,
1444
+ stateRoot: repoRoot,
1445
+ taskId: snapshotTaskId
1446
+ });
1447
+ const changedPaths = (options.diffWorkspace ?? diffWorkspaceSnapshots)(gitRoot, receipt.beforeFingerprint, afterFingerprint);
1286
1448
  return sealOrchestrationDelegation(matched.taskId, {
1287
1449
  childId: event.childId,
1288
1450
  exitCode: 0,
@@ -1474,8 +1636,10 @@ export {
1474
1636
  routeOrchestration,
1475
1637
  sealMatchingOrchestrationDelegation,
1476
1638
  sealOrchestrationDelegation,
1639
+ startCommitAttempt,
1477
1640
  statusCommitIntent,
1478
- statusOrchestration
1641
+ statusOrchestration,
1642
+ terminateCommitAttempt
1479
1643
  };
1480
1644
  export type {
1481
1645
  CommitIntentResult,
@@ -3,6 +3,13 @@ import path from "node:path";
3
3
 
4
4
  import { artifactName, maxRound } from "./review-artifacts.ts";
5
5
 
6
+ type ReviewedGitTree = string & { readonly __reviewedGitTree: unique symbol };
7
+ const REVIEWED_TREE_RE = /^[a-f0-9]{40,64}$/;
8
+
9
+ export function parseReviewedGitTree(value: string): ReviewedGitTree | null {
10
+ return REVIEWED_TREE_RE.test(value) ? value as ReviewedGitTree : null;
11
+ }
12
+
6
13
  // Paths excluded from the post-review coverage gate. Fail-closed: the gate
7
14
  // covers ALL tracked changes by default. This default denylist is EMPTY —
8
15
  // projects opt into exclusions explicitly via review.post_review_exclude_globs.
@@ -71,6 +78,8 @@ export function extractReviewedSnapshotTree(content: string): string {
71
78
  return match ? match[1]!.trim().replace(/`/g, "") : "";
72
79
  }
73
80
 
81
+ export type { ReviewedGitTree };
82
+
74
83
  export function parseReviewVerdict(content: string): string {
75
84
  const match = String(content).match(/^[-*]?\s*\*\*(?:总体结论|Overall Verdict)\*\*[::]\s*(.*?)\s*$/m);
76
85
  return match ? match[1]!.trim() : "";
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -18,6 +19,25 @@ type RepositorySnapshot = Readonly<{
18
19
  worktreeTree: string;
19
20
  }>;
20
21
 
22
+ type WorkspaceSnapshotContext = Readonly<{
23
+ gitRoot: string;
24
+ stateRoot: string;
25
+ taskId: string | null;
26
+ }>;
27
+
28
+ type TaskFileFingerprint = Readonly<{
29
+ path: string;
30
+ mode: number;
31
+ kind: 'file' | 'symlink';
32
+ sha256: string;
33
+ }>;
34
+
35
+ type OrchestrationWorkspaceFingerprint = Readonly<{
36
+ version: 2;
37
+ gitTree: string;
38
+ taskFiles: readonly TaskFileFingerprint[];
39
+ }>;
40
+
21
41
  function captureWorktreeTree(repoRoot: string, forcedPath: string | null): string {
22
42
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-orchestration-index-'));
23
43
  const indexFile = path.join(tempDir, 'index');
@@ -33,13 +53,72 @@ function captureWorktreeTree(repoRoot: string, forcedPath: string | null): strin
33
53
  }
34
54
  }
35
55
 
36
- function captureWorkspaceSnapshot(repoRoot: string, taskId: string | null): string {
56
+ function captureLegacyWorkspaceSnapshot(repoRoot: string, taskId: string | null): string {
37
57
  const workspaceRelative = taskId
38
58
  ? `.agents/workspace/active/${taskId}`
39
59
  : '.agents/workspace/active';
40
60
  return captureWorktreeTree(repoRoot, workspaceRelative);
41
61
  }
42
62
 
63
+ function taskFileFingerprints(stateRoot: string, taskId: string | null): TaskFileFingerprint[] {
64
+ const activeRoot = path.join(stateRoot, '.agents', 'workspace', 'active');
65
+ const roots = taskId ? [path.join(activeRoot, taskId)] : [activeRoot];
66
+ const records: TaskFileFingerprint[] = [];
67
+ const visit = (absolute: string, relative: string): void => {
68
+ const stat = fs.lstatSync(absolute);
69
+ if (stat.isDirectory()) {
70
+ for (const name of fs.readdirSync(absolute).sort()) {
71
+ visit(path.join(absolute, name), path.posix.join(relative, name));
72
+ }
73
+ return;
74
+ }
75
+ if (!stat.isFile() && !stat.isSymbolicLink()) return;
76
+ const content = stat.isSymbolicLink()
77
+ ? Buffer.from(fs.readlinkSync(absolute))
78
+ : fs.readFileSync(absolute);
79
+ records.push({
80
+ path: relative,
81
+ mode: stat.mode & 0o777,
82
+ kind: stat.isSymbolicLink() ? 'symlink' : 'file',
83
+ sha256: createHash('sha256').update(content).digest('hex')
84
+ });
85
+ };
86
+ for (const root of roots) {
87
+ if (!fs.existsSync(root)) continue;
88
+ const relative = path.posix.join('.agents/workspace/active', path.relative(activeRoot, root).split(path.sep).join('/'));
89
+ visit(root, relative.replace(/\/$/, ''));
90
+ }
91
+ return records.sort((left, right) => left.path.localeCompare(right.path));
92
+ }
93
+
94
+ function encodeWorkspaceFingerprint(value: OrchestrationWorkspaceFingerprint): string {
95
+ return `ws2:${Buffer.from(JSON.stringify(value)).toString('base64url')}`;
96
+ }
97
+
98
+ function decodeWorkspaceFingerprint(value: string): OrchestrationWorkspaceFingerprint | null {
99
+ if (!value.startsWith('ws2:')) return null;
100
+ try {
101
+ const parsed = JSON.parse(Buffer.from(value.slice(4), 'base64url').toString('utf8')) as OrchestrationWorkspaceFingerprint;
102
+ if (parsed.version !== 2 || !/^[a-f0-9]{40,64}$/.test(parsed.gitTree) || !Array.isArray(parsed.taskFiles)) {
103
+ throw new Error('invalid workspace fingerprint fields');
104
+ }
105
+ return parsed;
106
+ } catch (error) {
107
+ throw new Error(`ORCHESTRATION_SNAPSHOT_FAILED: ${error instanceof Error ? error.message : String(error)}`);
108
+ }
109
+ }
110
+
111
+ function captureWorkspaceSnapshot(context: WorkspaceSnapshotContext): string;
112
+ function captureWorkspaceSnapshot(repoRoot: string, taskId: string | null): string;
113
+ function captureWorkspaceSnapshot(contextOrRoot: WorkspaceSnapshotContext | string, taskId?: string | null): string {
114
+ if (typeof contextOrRoot === 'string') return captureLegacyWorkspaceSnapshot(contextOrRoot, taskId ?? null);
115
+ return encodeWorkspaceFingerprint({
116
+ version: 2,
117
+ gitTree: captureWorktreeTree(contextOrRoot.gitRoot, null),
118
+ taskFiles: taskFileFingerprints(contextOrRoot.stateRoot, contextOrRoot.taskId)
119
+ });
120
+ }
121
+
43
122
  function captureRepositorySnapshot(repoRoot: string): RepositorySnapshot {
44
123
  return {
45
124
  head: git(repoRoot, null, ['rev-parse', 'HEAD']).trim(),
@@ -49,9 +128,24 @@ function captureRepositorySnapshot(repoRoot: string): RepositorySnapshot {
49
128
  }
50
129
 
51
130
  function diffWorkspaceSnapshots(repoRoot: string, before: string, after: string): string[] {
52
- const output = git(repoRoot, null, ['diff', '--name-only', '-z', before, after]);
53
- return output.split('\0').filter(Boolean);
131
+ const beforeV2 = decodeWorkspaceFingerprint(before);
132
+ const afterV2 = decodeWorkspaceFingerprint(after);
133
+ if ((beforeV2 === null) !== (afterV2 === null)) {
134
+ throw new Error('ORCHESTRATION_SNAPSHOT_FAILED: workspace fingerprint versions cannot be mixed');
135
+ }
136
+ if (!beforeV2 || !afterV2) {
137
+ const output = git(repoRoot, null, ['diff', '--name-only', '-z', before, after]);
138
+ return output.split('\0').filter(Boolean);
139
+ }
140
+ const output = git(repoRoot, null, ['diff', '--name-only', '-z', beforeV2.gitTree, afterV2.gitTree]);
141
+ const changed = new Set(output.split('\0').filter(Boolean));
142
+ const beforeFiles = new Map(beforeV2.taskFiles.map((record) => [record.path, JSON.stringify(record)]));
143
+ const afterFiles = new Map(afterV2.taskFiles.map((record) => [record.path, JSON.stringify(record)]));
144
+ for (const filePath of new Set([...beforeFiles.keys(), ...afterFiles.keys()])) {
145
+ if (beforeFiles.get(filePath) !== afterFiles.get(filePath)) changed.add(filePath);
146
+ }
147
+ return [...changed].sort();
54
148
  }
55
149
 
56
150
  export { captureRepositorySnapshot, captureWorkspaceSnapshot, diffWorkspaceSnapshots };
57
- export type { RepositorySnapshot };
151
+ export type { OrchestrationWorkspaceFingerprint, RepositorySnapshot, WorkspaceSnapshotContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,13 +58,13 @@
58
58
  "demo:regen": "sh scripts/demo-regen.sh",
59
59
  "prepare": "git config core.hooksPath .git-hooks || true",
60
60
  "typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.jschecks.json --noEmit",
61
- "test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\"",
62
- "test:core": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\"",
63
- "test:integration": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/integration/**/*.test.ts\" \"tests/e2e/**/*.test.ts\"",
61
+ "test:smoke": "node scripts/run-tests.js \"tests/unit/**/*.test.ts\"",
62
+ "test:core": "node scripts/run-tests.js \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\"",
63
+ "test:integration": "node scripts/run-tests.js \"tests/integration/**/*.test.ts\" \"tests/e2e/**/*.test.ts\"",
64
64
  "check": "npm run typecheck && npm test",
65
- "test": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\"",
66
- "test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage \"--test-coverage-exclude=tests/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov \"tests/**/*.test.ts\"",
67
- "prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\""
65
+ "test": "node scripts/run-tests.js \"tests/**/*.test.ts\"",
66
+ "test:coverage": "node scripts/run-tests.js --experimental-test-coverage \"--test-coverage-exclude=tests/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov \"tests/**/*.test.ts\"",
67
+ "prepublishOnly": "node scripts/run-tests.js \"tests/**/*.test.ts\""
68
68
  },
69
69
  "devDependencies": {
70
70
  "@types/cross-spawn": "^6.0.6",
@@ -1,12 +1,15 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync } from 'node:fs';
4
+ import { readFileSync } from 'node:fs';
3
5
  import { fileURLToPath } from 'node:url';
4
6
 
5
7
  const MAX_INPUT_BYTES = 64 * 1024;
6
8
  const localInternalCli = fileURLToPath(new URL('../../dist/bin/internal-cli.js', import.meta.url));
9
+ const codexHooks = fileURLToPath(new URL('../../.codex/hooks.json', import.meta.url));
7
10
  const clientIndex = process.argv.indexOf('--client');
8
11
  const client = clientIndex >= 0 ? process.argv[clientIndex + 1] : '';
9
- if (client !== 'claude-code') {
12
+ if (client !== 'claude-code' && client !== 'codex') {
10
13
  process.stderr.write('Lifecycle delegation hook requires a supported --client value\n');
11
14
  process.exit(1);
12
15
  }
@@ -27,9 +30,50 @@ process.stdin.on('end', () => {
27
30
  process.stderr.write('Lifecycle delegation hook received invalid JSON\n');
28
31
  process.exit(1);
29
32
  }
30
- const hook = String(event.hook_event_name || event.event || '').toLowerCase();
31
- const nativeAgent = event.agent_name || event.agent_type || event.agent?.name;
33
+ const phaseIndex = process.argv.indexOf('--event');
34
+ const explicitPhase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : '';
35
+ const hook = String(explicitPhase || event.hook_event_name || event.event || '').toLowerCase();
36
+ const toolInput = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
37
+ const nativeAgent = event.agent_name || event.agent_type || event.agent?.name
38
+ || toolInput.agent_type || toolInput.agent || toolInput.name;
32
39
  if (!String(nativeAgent || '').startsWith('agent-infra-lifecycle-')) process.exit(0);
40
+ if (client === 'codex') {
41
+ const phases = new Set(['pre-tool', 'subagent-start', 'post-tool', 'subagent-stop']);
42
+ if (!phases.has(hook)) {
43
+ process.stderr.write('Managed Codex lifecycle hook event has an unknown type\n');
44
+ process.exit(1);
45
+ }
46
+ const hookDefinitionHash = createHash('sha256').update(readFileSync(codexHooks)).digest('hex');
47
+ const normalized = {
48
+ sessionId: String(event.session_id || ''),
49
+ turnId: String(event.turn_id || ''),
50
+ toolUseId: String(event.tool_use_id || ''),
51
+ childThreadId: String(event.agent_id || event.child_id || event.thread_id || event.tool_response?.agent_id || ''),
52
+ nativeAgent: String(nativeAgent),
53
+ requestedModel: String(toolInput.model || ''),
54
+ requestedReasoningEffort: String(toolInput.reasoning_effort || toolInput.reasoningEffort || ''),
55
+ hookDefinitionHash
56
+ };
57
+ const args = ['codex-lifecycle', 'hook-event', '--event', hook];
58
+ try {
59
+ const useLocal = existsSync(localInternalCli);
60
+ const command = useLocal ? process.execPath : 'agent-infra-internal';
61
+ const commandArgs = useLocal ? [localInternalCli, ...args] : args;
62
+ const output = execFileSync(command, commandArgs, {
63
+ encoding: 'utf8',
64
+ input: JSON.stringify(normalized),
65
+ shell: !useLocal && process.platform === 'win32',
66
+ stdio: ['pipe', 'pipe', 'pipe'],
67
+ timeout: 5_000
68
+ });
69
+ process.stdout.write(output);
70
+ } catch (error) {
71
+ if (error.stdout) process.stdout.write(String(error.stdout));
72
+ if (error.stderr) process.stderr.write(String(error.stderr));
73
+ process.exit(typeof error.status === 'number' ? error.status : 1);
74
+ }
75
+ return;
76
+ }
33
77
  const start = hook.includes('start');
34
78
  const stop = hook.includes('stop');
35
79
  if (!start && !stop) {
@@ -68,6 +68,7 @@ The six internal commands (`task-event` / `task-lifecycle` / `platform-issue` /
68
68
  `- {time} — **{base} [started]** by {agent} — started`
69
69
  - **done line** (written when the step completes, unchanged from today): the action is the base itself:
70
70
  `- {time} — **{base}** by {agent} — {completion summary}`
71
+ - **Commit-specific attempt**: the commit skill must not hand-write started. `commit-start` writes a structured `Commit [started]` with `attempt`, `baseline`, and `agent` under the task lock; `commit-terminate` writes the matching `Commit [aborted]` when an attempt is abandoned before side effects. Aborted closes activity only and is never successful commit evidence.
71
72
  - `{base}` is that skill's existing done action text, including `(Round {N})` (e.g. `Plan Task (Round 1)`). started and done must share the same `{base}` to pair.
72
73
 
73
74
  **Pairing and rendering** (`ai task log`): a started entry pairs with the next same-`{base}` done entry onto one row (repeated executions of the same base pair FIFO by ascending time). The STARTED column shows the start time, DONE the completion time; started with no done = in progress (DONE shows `(in progress)`); done with no started (legacy logs) = a standalone completed row. All three shapes are valid and never error.
@@ -70,6 +70,7 @@
70
70
  `- {time} — **{基名} [started]** by {agent} — started`
71
71
  - **done 行**(步骤完成时写,与现状一致):action 即基名本身:
72
72
  `- {time} — **{基名}** by {agent} — {完成说明}`
73
+ - **Commit 专用 attempt**:commit 技能不得手写 started。`commit-start` 在任务锁内写入带 `attempt`、`baseline`、`agent` 的结构化 `Commit [started]`;无副作用放弃时由 `commit-terminate` 写匹配的 `Commit [aborted]`。aborted 只闭合活动日志,不构成提交成功证据。
73
74
  - `{基名}` 指该 SKILL 既有 done 条目的 action 文本,含 `(Round {N})`(如 `Plan Task (Round 1)`)。
74
75
  started 与 done 共用同一 `{基名}` 才能配对。
75
76
 
@@ -31,17 +31,11 @@ Direct invocation still requires explicit user authorization. An orchestrated in
31
31
 
32
32
  Without explicit task scope, only `TASK_CONTEXT_NOT_FOUND` may continue through the existing bare-commit path. Detached HEAD, damaged candidates, or multiple matches are ambiguity errors. Any explicit task-scope failure is fatal.
33
33
 
34
- ## Step Start: Recover First, Then Write the started Marker
34
+ ## Step Start: Recover or Create a Controlled Attempt
35
35
 
36
- When `{task-id}` is resolved, call `commit-status` first. For `recoverable` / `prepared`, call `commit-recover --agent {standard-agent-token}` without adding a second started entry or repeating Git side effects. Fail closed for `invalid` / `conflict` / `orphaned-start`; only `idle` enters the normal flow.
36
+ When `{task-id}` is resolved, call `commit-status` first. Finalize `recoverable`; safely clear `prepared` and query status again; reuse `retryable-start` through `commit-recover --agent {standard-agent-token}`. Fail closed for `invalid` / `conflict` / `orphaned-start`. For `idle`, call `commit-start --agent {standard-agent-token}` so the core atomically writes the structured started entry and returns its attempt/baseline.
37
37
 
38
- Before checking local modifications, append a started marker to task.md `## Activity Log` (same base action as this step's done entry plus a ` [started]` suffix, note `started`):
39
-
40
- ```
41
- - {YYYY-MM-DD HH:mm:ss±HH:MM} — **Commit [started]** by {agent} — started
42
- ```
43
-
44
- `ai task log` pairs it with the done entry written when the commit completes onto one row (in progress → done). Format and pairing rules: see the "Activity Log started / done dual-marker convention" in `.agents/rules/task-management.md`. Only write it when this task has a task.md (a bare commit with no task context may skip it).
38
+ Read `{commit-attempt}` only from structured `commit-start` / `commit-recover` output. Never hand-write, copy, or guess an attempt. A bare commit without task context skips this protocol.
45
39
 
46
40
  ## 1. Check Local Modifications (CRITICAL)
47
41