@fitlab-ai/agent-infra 0.11.8 → 0.11.9

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 (45) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/lib/agent-clients/custom-tool-invocations.js +92 -0
  4. package/dist/lib/agent-clients/next-steps.js +1 -1
  5. package/dist/lib/agent-clients/reconcile.js +3 -3
  6. package/dist/lib/agent-clients/schema.js +18 -10
  7. package/dist/lib/init.js +2 -2
  8. package/dist/lib/internal/agent-client.js +3 -3
  9. package/dist/lib/sandbox/recovery.js +1 -22
  10. package/dist/lib/sandbox/tools.js +2 -0
  11. package/dist/lib/task/finalization.js +90 -93
  12. package/dist/lib/task/verification.js +0 -1
  13. package/dist/lib/update.js +2 -2
  14. package/dist/lifecycle-build-manifest.json +3 -3
  15. package/lib/agent-clients/custom-tool-invocations.ts +129 -0
  16. package/lib/agent-clients/next-steps.ts +3 -3
  17. package/lib/agent-clients/reconcile.ts +3 -3
  18. package/lib/agent-clients/schema.ts +18 -10
  19. package/lib/init.ts +2 -2
  20. package/lib/internal/agent-client.ts +3 -3
  21. package/lib/sandbox/recovery.ts +0 -25
  22. package/lib/sandbox/tool-types.ts +1 -0
  23. package/lib/sandbox/tools.ts +2 -0
  24. package/lib/task/finalization.ts +91 -88
  25. package/lib/task/verification.ts +1 -2
  26. package/lib/update.ts +2 -3
  27. package/package.json +1 -1
  28. package/templates/.agents/README.en.md +2 -50
  29. package/templates/.agents/README.zh-CN.md +2 -50
  30. package/templates/.agents/rules/next-step-output.en.md +1 -1
  31. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  32. package/templates/.agents/scripts/lib/agent-infra-package.js +0 -7
  33. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  34. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  35. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  36. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  37. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  38. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  39. package/templates/.agents/skills/review-code/reference/output-templates.en.md +15 -6
  40. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +15 -6
  41. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -1
  42. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -1
  43. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +2 -268
  44. package/dist/lib/agent-clients/custom-tuis.js +0 -99
  45. package/lib/agent-clients/custom-tuis.ts +0 -149
@@ -0,0 +1,129 @@
1
+ type CustomToolInvocation = Readonly<{
2
+ name: string;
3
+ invocation: string;
4
+ }>;
5
+
6
+ type CustomToolInvocationDiagnosticCode =
7
+ | 'INVALID_CUSTOM_TOOL_CONFIG'
8
+ | 'INVALID_CUSTOM_TOOL_INVOCATION'
9
+ | 'INVALID_CUSTOM_TOOL_INVOCATION_PLACEHOLDER';
10
+
11
+ type CustomToolInvocationDiagnostic = Readonly<{
12
+ code: CustomToolInvocationDiagnosticCode;
13
+ path: string;
14
+ }>;
15
+
16
+ type NormalizeCustomToolInvocationsResult = Readonly<{
17
+ items: readonly CustomToolInvocation[];
18
+ diagnostics: readonly CustomToolInvocationDiagnostic[];
19
+ }>;
20
+
21
+ const ALLOWED_PLACEHOLDERS = Object.freeze(['skillName', 'projectName']);
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ function isNonEmptySingleLine(value: unknown): value is string {
28
+ return typeof value === 'string'
29
+ && value.trim() !== ''
30
+ && !/[\r\n]/.test(value);
31
+ }
32
+
33
+ function hasValidPlaceholders(invocation: string): boolean {
34
+ const placeholders = [
35
+ ...invocation.matchAll(/\$\{([^}]+)\}/g)
36
+ ].map((match) => match[1]!);
37
+ return placeholders.includes('skillName')
38
+ && placeholders.every((placeholder) =>
39
+ ALLOWED_PLACEHOLDERS.includes(placeholder)
40
+ )
41
+ && !invocation
42
+ .replaceAll('${skillName}', '')
43
+ .replaceAll('${projectName}', '')
44
+ .includes('${');
45
+ }
46
+
47
+ function normalizeCustomToolInvocations(config: unknown): NormalizeCustomToolInvocationsResult {
48
+ const items: CustomToolInvocation[] = [];
49
+ const diagnostics: CustomToolInvocationDiagnostic[] = [];
50
+
51
+ const root = isRecord(config) ? config : {};
52
+ const sandbox = isRecord(root.sandbox) ? root.sandbox : {};
53
+ const sandboxTools = isRecord(sandbox.tools) ? sandbox.tools : { ids: ['agent-infra'] };
54
+ const definitions = sandboxTools.definitions ?? {};
55
+ const selectedIds = sandboxTools.ids;
56
+
57
+ if (!Array.isArray(selectedIds)) {
58
+ return Object.freeze({
59
+ items: Object.freeze([]),
60
+ diagnostics: Object.freeze([
61
+ Object.freeze({
62
+ code: 'INVALID_CUSTOM_TOOL_CONFIG' as const,
63
+ path: 'sandbox.tools.ids'
64
+ })
65
+ ])
66
+ });
67
+ }
68
+
69
+ if (!isRecord(definitions)) {
70
+ return Object.freeze({
71
+ items: Object.freeze([]),
72
+ diagnostics: Object.freeze([
73
+ Object.freeze({
74
+ code: 'INVALID_CUSTOM_TOOL_CONFIG' as const,
75
+ path: 'sandbox.tools.definitions'
76
+ })
77
+ ])
78
+ });
79
+ }
80
+
81
+ for (const [index, id] of selectedIds.entries()) {
82
+ if (typeof id !== 'string') {
83
+ diagnostics.push({
84
+ code: 'INVALID_CUSTOM_TOOL_INVOCATION',
85
+ path: `sandbox.tools.ids[${index}]`
86
+ });
87
+ continue;
88
+ }
89
+ const candidate = definitions[id];
90
+ if (!isRecord(candidate)) continue;
91
+ if (candidate.invoke === undefined) continue;
92
+ const base = `sandbox.tools.definitions.${id}`;
93
+ const name = candidate.name === undefined ? id : candidate.name;
94
+ if (!isNonEmptySingleLine(name)) {
95
+ diagnostics.push({ code: 'INVALID_CUSTOM_TOOL_INVOCATION', path: `${base}.name` });
96
+ continue;
97
+ }
98
+ if (!isNonEmptySingleLine(candidate.invoke)) {
99
+ diagnostics.push({ code: 'INVALID_CUSTOM_TOOL_INVOCATION', path: `${base}.invoke` });
100
+ continue;
101
+ }
102
+ if (!hasValidPlaceholders(candidate.invoke)) {
103
+ diagnostics.push({
104
+ code: 'INVALID_CUSTOM_TOOL_INVOCATION_PLACEHOLDER',
105
+ path: `${base}.invoke`
106
+ });
107
+ continue;
108
+ }
109
+ items.push(Object.freeze({
110
+ name,
111
+ invocation: candidate.invoke
112
+ }));
113
+ }
114
+
115
+ return Object.freeze({
116
+ items: Object.freeze(items),
117
+ diagnostics: Object.freeze(
118
+ diagnostics.map((diagnostic) => Object.freeze(diagnostic))
119
+ )
120
+ });
121
+ }
122
+
123
+ export { normalizeCustomToolInvocations };
124
+ export type {
125
+ CustomToolInvocation,
126
+ CustomToolInvocationDiagnostic,
127
+ CustomToolInvocationDiagnosticCode,
128
+ NormalizeCustomToolInvocationsResult
129
+ };
@@ -2,7 +2,7 @@ import { listEnabledAgentClientAdapters } from './registry.ts';
2
2
  import { renderAgentClientInvocation } from './invocation.ts';
3
3
  import semver from 'semver';
4
4
  import { isTaskScopeSkill } from '../task/skill-scope.ts';
5
- import type { CustomTUI } from './custom-tuis.ts';
5
+ import type { CustomToolInvocation } from './custom-tool-invocations.ts';
6
6
  import type { AgentClientId, AgentClientState } from './types.ts';
7
7
 
8
8
  type NextStepCommand = Readonly<{
@@ -15,7 +15,7 @@ type NextStepCommand = Readonly<{
15
15
  type RenderNextStepsInput = Readonly<{
16
16
  projectName: string;
17
17
  state: AgentClientState;
18
- customTUIs: readonly CustomTUI[];
18
+ customToolInvocations: readonly CustomToolInvocation[];
19
19
  skillName: string;
20
20
  taskRef?: string;
21
21
  version?: string;
@@ -69,7 +69,7 @@ function renderNextStepCommands(
69
69
  )
70
70
  })
71
71
  );
72
- const custom = input.customTUIs.map(
72
+ const custom = input.customToolInvocations.map(
73
73
  (tool): NextStepCommand => Object.freeze({
74
74
  source: 'custom',
75
75
  displayName: tool.name,
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  import defaults from '../defaults.json' with { type: 'json' };
6
6
  import { normalizeAgentClients, serializeAgentClients } from './config.ts';
7
- import { normalizeCustomTUIs } from './custom-tuis.ts';
7
+ import { normalizeCustomToolInvocations } from './custom-tool-invocations.ts';
8
8
  import { renderNextStepCommands } from './next-steps.ts';
9
9
  import { planAgentClientProjectAssets } from './project-assets.ts';
10
10
  import {
@@ -216,11 +216,11 @@ function planAgentClientReconciliation(input: Readonly<{
216
216
  delete (nextConfig.files as Record<string, unknown>).managedBaselines;
217
217
  }
218
218
 
219
- const custom = normalizeCustomTUIs(input.projectRoot, input.config.customTUIs ?? []);
219
+ const custom = normalizeCustomToolInvocations(input.config);
220
220
  const nextSteps = renderNextStepCommands({
221
221
  projectName,
222
222
  state: desired,
223
- customTUIs: custom.items,
223
+ customToolInvocations: custom.items,
224
224
  skillName: 'update-agent-infra'
225
225
  });
226
226
  const actionableSeed = seedOperations.some((operation) =>
@@ -43,16 +43,24 @@ const AGENT_CLIENTS_SCHEMA = {
43
43
  }
44
44
  }))
45
45
  },
46
- customTUIs: {
47
- type: 'array',
48
- items: {
49
- type: 'object',
50
- additionalProperties: true,
51
- required: ['name', 'dir', 'invoke'],
52
- properties: {
53
- name: { type: 'string' },
54
- dir: { type: 'string' },
55
- invoke: { type: 'string' }
46
+ sandbox: {
47
+ type: 'object',
48
+ properties: {
49
+ tools: {
50
+ type: 'object',
51
+ properties: {
52
+ ids: { type: 'array', items: { type: 'string' } },
53
+ definitions: {
54
+ type: 'object',
55
+ additionalProperties: {
56
+ type: 'object',
57
+ properties: {
58
+ name: { type: 'string' },
59
+ invoke: { type: 'string' }
60
+ }
61
+ }
62
+ }
63
+ }
56
64
  }
57
65
  }
58
66
  }
package/lib/init.ts CHANGED
@@ -314,8 +314,8 @@ async function cmdInit(): Promise<void> {
314
314
  console.log(' npm install -g @fitlab-ai/agent-infra');
315
315
  console.log('');
316
316
  if (workflowPlan.nextSteps.length === 0) {
317
- console.log(' No Agent Client project integration enabled.');
318
- console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
317
+ console.log(' No Agent Client or sandbox custom tool integration enabled.');
318
+ console.log(` Configure a custom tool in "sandbox.tools.definitions" and select it in "sandbox.tools.ids" in ${configPath}.`);
319
319
  console.log('');
320
320
  } else {
321
321
  console.log(' Next step: open this project in any AI TUI and run:');
@@ -5,7 +5,7 @@ import {
5
5
  AgentClientConfigError,
6
6
  normalizeAgentClients
7
7
  } from '../agent-clients/config.ts';
8
- import { normalizeCustomTUIs } from '../agent-clients/custom-tuis.ts';
8
+ import { normalizeCustomToolInvocations } from '../agent-clients/custom-tool-invocations.ts';
9
9
  import { renderNextStepCommands } from '../agent-clients/next-steps.ts';
10
10
  import { getAgentClientModelSelection } from '../agent-clients/registry.ts';
11
11
  import { isAgentClientId } from '../agent-clients/types.ts';
@@ -148,11 +148,11 @@ function agentClient(args: string[] = []): void {
148
148
 
149
149
  try {
150
150
  const clients = normalizeAgentClients(config);
151
- const custom = normalizeCustomTUIs(process.cwd(), config.customTUIs ?? []);
151
+ const custom = normalizeCustomToolInvocations(config);
152
152
  const commands = renderNextStepCommands({
153
153
  projectName: String(config.project ?? ''),
154
154
  state: clients.state,
155
- customTUIs: custom.items,
155
+ customToolInvocations: custom.items,
156
156
  skillName: parsed.skillName,
157
157
  ...(parsed.taskRef ? { taskRef: parsed.taskRef } : {}),
158
158
  ...(parsed.version ? { version: parsed.version } : {})
@@ -8,12 +8,10 @@ import {
8
8
  containerNameCandidates,
9
9
  sandboxBranchLabel,
10
10
  sandboxLabel,
11
- sandboxRuntimeCapabilityLabel,
12
11
  sandboxTaskIdLabel,
13
12
  sandboxWorkspaceModeLabel,
14
13
  worktreeDirCandidates
15
14
  } from './constants.ts';
16
- import { isAgentClientId } from '../agent-clients/types.ts';
17
15
  import {
18
16
  createSandboxCapabilityPlan
19
17
  } from './agent-client-reconciler.ts';
@@ -100,8 +98,6 @@ export type SandboxRecoverySnapshot = {
100
98
  workspaceTopology?: 'legacy-parent' | 'per-state' | 'unknown';
101
99
  taskView?: { path: string; readable: boolean };
102
100
  runtimeStoreOk?: boolean;
103
- runtimeCapabilityOk?: boolean;
104
- unexpectedCapabilityMounts?: string[];
105
101
  mounts: Array<{
106
102
  path: string;
107
103
  expectedType: string;
@@ -452,12 +448,6 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
452
448
  : `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
453
449
  });
454
450
  }
455
- if (snapshot.runtimeCapabilityOk === false) {
456
- findings.push({
457
- repairKind: 'hard-failure',
458
- message: 'Container runtime capability signature does not match the current sandbox plan.'
459
- });
460
- }
461
451
  if (snapshot.workspaceTopology === 'legacy-parent') {
462
452
  findings.push({
463
453
  repairKind: 'hard-failure',
@@ -482,14 +472,6 @@ export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): Sand
482
472
  path: '/run/agent-infra/runtime'
483
473
  });
484
474
  }
485
- for (const mountPath of snapshot.unexpectedCapabilityMounts ?? []) {
486
- findings.push({
487
- repairKind: 'hard-failure',
488
- message: `Disabled Agent Client capability remains mounted at ${mountPath}.`,
489
- path: mountPath
490
- });
491
- }
492
-
493
475
  for (const mount of snapshot.mounts) {
494
476
  if (
495
477
  snapshot.workspaceTopology === 'legacy-parent'
@@ -964,11 +946,6 @@ export function collectSandboxRecoverySnapshot(params: {
964
946
  mode: sandboxWorkspaceModeLabel(params.config),
965
947
  taskId: sandboxTaskIdLabel(params.config)
966
948
  });
967
- const selectedToolIds = new Set(tools.map((tool) => tool.id));
968
- const unexpectedCapabilityMounts = capabilityPlan.cleanupInventory
969
- .filter((tool) => isAgentClientId(tool.id) && !selectedToolIds.has(tool.id))
970
- .map((tool) => tool.containerMount)
971
- .filter((mountPath) => mountsByDestination.has(mountPath));
972
949
  const mountSnapshots = expectedMounts({
973
950
  config: params.config,
974
951
  branch: params.branch,
@@ -1035,8 +1012,6 @@ export function collectSandboxRecoverySnapshot(params: {
1035
1012
  identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
1036
1013
  && branchLabel === params.branch
1037
1014
  && sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
1038
- runtimeCapabilityOk: labels[sandboxRuntimeCapabilityLabel(params.config)] === capabilityPlan.runtimeSignature,
1039
- unexpectedCapabilityMounts,
1040
1015
  mounts: mountSnapshots,
1041
1016
  tmpfs,
1042
1017
  seeds,
@@ -10,6 +10,7 @@ type SandboxTool = {
10
10
  containerMount: string;
11
11
  versionCmd: string;
12
12
  setupHint: string;
13
+ invoke?: string;
13
14
  envVars?: Record<string, string>;
14
15
  hostPreSeedFiles?: Array<{ hostPath: string; sandboxName: string }>;
15
16
  hostPreSeedDirs?: Array<{ hostDir: string; sandboxSubdir: string }>;
@@ -231,6 +231,7 @@ export function parseCustomTool(
231
231
  if (!containerMount.startsWith('/')) {
232
232
  throw new Error(`${context}: field "containerMount" must be an absolute path`);
233
233
  }
234
+ const invoke = asOptionalNonEmptyString(entry.invoke, 'invoke', context);
234
235
 
235
236
  const tool: SandboxTool = {
236
237
  id,
@@ -241,6 +242,7 @@ export function parseCustomTool(
241
242
  versionCmd: asOptionalNonEmptyString(entry.versionCmd, 'versionCmd', context) ?? `which ${id}`,
242
243
  setupHint: asOptionalNonEmptyString(entry.setupHint, 'setupHint', context)
243
244
  ?? `Run \`${id}\` inside the container to set up.`,
245
+ ...(invoke === undefined ? {} : { invoke }),
244
246
  envVars: asStringRecord(entry.envVars, 'envVars', context),
245
247
  hostPreSeedFiles: parseHostPreSeedFiles(entry.hostPreSeedFiles, context),
246
248
  hostPreSeedDirs: parseHostPreSeedDirs(entry.hostPreSeedDirs, context),
@@ -795,9 +795,12 @@ function terminalResult(
795
795
  const blocked = [steps.backfill, steps.lifecycle, steps.taskComment, steps.verification, steps.summary].some((step) => step?.status === 'blocked');
796
796
  const warnings = openWarnings(receipt);
797
797
  const postLifecyclePending = receipt.lifecycle === 'done' && (pending.some((step) => step !== 'lifecycle') || receipt.warningProjection === 'pending');
798
- const hardError = error?.code.startsWith('FINALIZATION_') || error?.code === 'TASK_FINALIZATION_RECEIPT_INVALID';
798
+ const terminalVerificationPending = receipt.lifecycle === 'done' && receipt.verification === 'pending';
799
+ const resultError = error ?? (terminalVerificationPending ? steps.verification?.error : null) ?? null;
800
+ const hardError = error?.code.startsWith('FINALIZATION_') || error?.code === 'TASK_FINALIZATION_RECEIPT_INVALID'
801
+ || terminalVerificationPending;
799
802
  return {
800
- status: hardError ? (error?.retryable ? 'blocked' : 'failed') : pending.length === 0 ? 'completed' : postLifecyclePending ? 'completed' : blocked ? 'blocked' : 'failed',
803
+ status: hardError ? (resultError?.retryable ? 'blocked' : 'failed') : pending.length === 0 ? 'completed' : postLifecyclePending ? 'completed' : blocked ? 'blocked' : 'failed',
801
804
  changed,
802
805
  taskId,
803
806
  backfill: steps.backfill ?? null,
@@ -808,7 +811,7 @@ function terminalResult(
808
811
  completedSteps: completedSteps(receipt),
809
812
  pendingSteps: pending,
810
813
  result: hardError
811
- ? (error?.retryable ? 'blocked' : 'failed')
814
+ ? (resultError?.retryable ? 'blocked' : 'failed')
812
815
  : postLifecyclePending
813
816
  ? 'completed_with_warnings'
814
817
  : pending.length === 0
@@ -817,7 +820,7 @@ function terminalResult(
817
820
  ? 'blocked'
818
821
  : 'failed',
819
822
  warnings,
820
- error: hardError ? error : postLifecyclePending ? null : error
823
+ error: hardError ? resultError : postLifecyclePending ? null : resultError
821
824
  };
822
825
  }
823
826
 
@@ -850,7 +853,6 @@ async function prepareUnderLock(
850
853
  const commentSync = options.commentSync ?? syncPlatformComment;
851
854
  const issueSync = options.issueSync ?? syncPlatformIssue;
852
855
  const issueInspect = options.issueInspect ?? inspectPlatformIssue;
853
- const verify = options.verify ?? verifyTaskEvent;
854
856
  const consumedCapabilities = new Set<string>();
855
857
  let receipt: TaskFinalizationReceipt;
856
858
  try {
@@ -922,6 +924,7 @@ async function prepareUnderLock(
922
924
  const badOperation = issueOperations?.find((operation) =>
923
925
  ['requirements', 'issue-type', 'fields', ...(base ? ['labels:in'] : [])].includes(operation.name)
924
926
  && operation.status !== 'applied' && operation.status !== 'no-op'
927
+ && !(operation.name === 'requirements' && operation.status === 'skipped' && operation.reasonCode === 'NO_REQUIREMENTS_ANCHOR')
925
928
  );
926
929
  if ((synced.status !== 'applied' && synced.status !== 'no-op') || synced.error || badOperation) {
927
930
  const error = synced.error ?? {
@@ -1080,84 +1083,9 @@ async function prepareUnderLock(
1080
1083
  })
1081
1084
  : undefined;
1082
1085
 
1083
- let verification: TaskFinalizationStep | null = null;
1084
- if (receipt.verification !== 'pending') {
1085
- verification = { status: receipt.verification === 'skipped' ? 'skipped' : 'no-op', changed: false, error: null };
1086
- } else {
1087
- try {
1088
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1089
- const verificationState = resolveTaskRef(taskId, { repoRoot });
1090
- const verificationEvent = verificationState.ok && verificationState.state === 'completed'
1091
- ? 'complete-task.completed'
1092
- : 'complete-task.prepared';
1093
- const result = await verify(
1094
- { taskRef: taskId, event: verificationEvent },
1095
- { repoRoot }
1096
- );
1097
- verification = verificationStep(result);
1098
- if (result.status === 'pass') {
1099
- const resolvedTaskWarning = receipt.warnings.some((warning) => warning.step === 'verification' && warning.status === 'open');
1100
- const warnings = applyVerificationWarnings(receipt, result);
1101
- receipt = updateReceipt(repoRoot, receipt, {
1102
- verification: 'done', taskComment: resolvedTaskWarning ? 'pending' : receipt.taskComment,
1103
- warningProjection: warnings.length > 0 ? 'pending' : 'done', warnings, lastError: null
1104
- });
1105
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1106
- const finalComment = await syncPendingTaskComment({
1107
- repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities
1108
- });
1109
- receipt = finalComment.receipt;
1110
- if (finalComment.step.status !== 'no-op') taskComment = finalComment.step;
1111
- changed = changed || finalComment.changed;
1112
- if (finalComment.error) {
1113
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, finalComment.error);
1114
- }
1115
- } else {
1116
- const detail = verification.error ?? { code: 'VERIFY_FAILED', message: 'verification failed', retryable: true };
1117
- const warnings = applyVerificationWarnings(receipt, result);
1118
- receipt = updateReceipt(repoRoot, receipt, {
1119
- verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
1120
- });
1121
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1122
- const warningComment = await syncPendingTaskComment({
1123
- repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities
1124
- });
1125
- receipt = warningComment.receipt;
1126
- taskComment = warningComment.step;
1127
- changed = changed || warningComment.changed;
1128
- if (warningComment.error) {
1129
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, warningComment.error);
1130
- }
1131
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification }, changed, detail);
1132
- }
1133
- } catch (error) {
1134
- const detail = errorOf(error, 'VERIFY_FAILED', true);
1135
- try {
1136
- const warnings = replaceWarning(receipt, warningFromError('verification', detail), 'open');
1137
- receipt = updateReceipt(repoRoot, receipt, {
1138
- verification: 'pending', taskComment: 'pending', warningProjection: 'pending', warnings, lastError: detail
1139
- });
1140
- receipt = reconcileWarningProjection(repoRoot, taskId, receipt, consumedCapabilities);
1141
- } catch (writeError) {
1142
- const persistence = errorOf(writeError, 'FINALIZATION_RECEIPT_WRITE_FAILED', true);
1143
- const hardError: FinalizationError = {
1144
- code: 'FINALIZATION_RECEIPT_WRITE_FAILED',
1145
- message: `Unable to persist verification failure (${detail.code}: ${detail.message}): ${persistence.message}`,
1146
- retryable: true
1147
- };
1148
- return terminalResult(taskId, receipt, {
1149
- backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification: { status: 'blocked', changed: false, error: detail }
1150
- }, changed, hardError);
1151
- }
1152
- try {
1153
- const warningComment = await syncPendingTaskComment({ repoRoot, taskId, agent: request.agent, receipt, projection: currentProjection(), refreshProjection: currentProjection, commentSync, consumedCapabilities });
1154
- receipt = warningComment.receipt;
1155
- if (warningComment.step.status !== 'no-op') taskComment = warningComment.step;
1156
- changed = changed || warningComment.changed;
1157
- } catch { /* preserve the primary error */ }
1158
- return terminalResult(taskId, receipt, { backfill: backfillResult, lifecycle: lifecycleResult, taskComment, verification: { status: 'blocked', changed: false, error: detail } }, changed, detail);
1159
- }
1160
- }
1086
+ const verification: TaskFinalizationStep = receipt.verification === 'done'
1087
+ ? { status: 'no-op', changed: false, error: null }
1088
+ : { status: 'skipped', changed: false, error: null };
1161
1089
 
1162
1090
  let summary = receipt.summary === 'pending'
1163
1091
  ? await syncPendingSummary({ repoRoot, taskId, agent: request.agent, receipt, commentSync })
@@ -1208,7 +1136,7 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1208
1136
  const resolved = resolveTaskRef(request.taskRef, { repoRoot });
1209
1137
  if (!resolved.ok) return failed(resolved.taskId, { code: resolved.code, message: resolved.message, retryable: false });
1210
1138
  try {
1211
- return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.commit', () => {
1139
+ return await withTaskExecutionLock(repoRoot, resolved.taskId, 'task-finalization.commit', async () => {
1212
1140
  if (options.controlBinding && options.manifestPath) {
1213
1141
  try {
1214
1142
  const manifest = readSandboxControlManifest(options.manifestPath);
@@ -1255,7 +1183,7 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1255
1183
  if (!receipt) return failed(resolved.taskId, {
1256
1184
  code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation receipt is unavailable', retryable: true
1257
1185
  });
1258
- if (receipt.lifecycle === 'done' && resolved.state === 'completed') {
1186
+ if (receipt.lifecycle === 'done' && receipt.verification !== 'pending' && resolved.state === 'completed') {
1259
1187
  return terminalResult(resolved.taskId, receipt, { lifecycle: { status: 'no-op', changed: false, error: null } }, false);
1260
1188
  }
1261
1189
  if (options.controlBinding) {
@@ -1291,7 +1219,7 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1291
1219
  }
1292
1220
  }
1293
1221
  }
1294
- if (receipt.taskComment === 'pending' || receipt.verification === 'pending' || receipt.summary === 'pending' || receipt.warningProjection === 'pending') {
1222
+ if (receipt.taskComment === 'pending' || receipt.summary === 'pending' || receipt.warningProjection === 'pending') {
1295
1223
  return failed(resolved.taskId, {
1296
1224
  code: 'TASK_FINALIZATION_PREPARATION_REQUIRED', message: 'sandbox preparation has pending external steps', retryable: true
1297
1225
  }, { completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt) });
@@ -1302,6 +1230,7 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1302
1230
  message: `cannot verify canonical short-id registry: ${registry.error.code}: ${registry.error.message}`,
1303
1231
  retryable: false
1304
1232
  });
1233
+ receipt = updateReceipt(repoRoot, receipt, { verification: 'pending' });
1305
1234
  const lifecycle = options.lifecycle ?? applyTaskLifecycle;
1306
1235
  const result = lifecycle(
1307
1236
  { taskRef: resolved.taskId, intent: 'complete', agent: request.agent },
@@ -1312,8 +1241,82 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1312
1241
  const updated = updateReceipt(repoRoot, receipt, { lifecycle: 'pending', lastError: step.error });
1313
1242
  return terminalResult(resolved.taskId, updated, { lifecycle: step }, result.changed, step.error);
1314
1243
  }
1315
- const updated = updateReceipt(repoRoot, receipt, { lifecycle: 'done', lastError: null });
1316
- return terminalResult(resolved.taskId, updated, { lifecycle: step }, result.changed);
1244
+
1245
+ let verificationResult: TaskVerificationResult;
1246
+ try {
1247
+ verificationResult = await (options.verify ?? verifyTaskEvent)(
1248
+ { taskRef: resolved.taskId, event: 'complete-task.completed' },
1249
+ { repoRoot }
1250
+ );
1251
+ } catch (error) {
1252
+ const detail = errorOf(error, 'VERIFY_FAILED', true);
1253
+ const warnings = replaceWarning(receipt, warningFromError('verification', detail), 'open');
1254
+ let updated: TaskFinalizationReceipt;
1255
+ try {
1256
+ updated = updateReceipt(repoRoot, receipt, {
1257
+ lifecycle: 'done', verification: 'pending', taskComment: 'pending', warningProjection: 'pending',
1258
+ warnings, lastError: detail
1259
+ });
1260
+ } catch (persistCause) {
1261
+ const persistError = errorOf(persistCause, 'FINALIZATION_RECEIPT_WRITE_FAILED', true);
1262
+ persistError.code = 'FINALIZATION_RECEIPT_WRITE_FAILED';
1263
+ persistError.message = `${persistError.message}; verification also failed: ${detail.code}: ${detail.message}`;
1264
+ return failed(resolved.taskId, persistError, {
1265
+ changed: result.changed,
1266
+ lifecycle: step,
1267
+ verification: { status: 'blocked', changed: false, error: persistError },
1268
+ completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt)
1269
+ });
1270
+ }
1271
+ const verification: TaskFinalizationStep = { status: detail.retryable ? 'blocked' : 'failed', changed: false, error: detail };
1272
+ return terminalResult(resolved.taskId, updated, { lifecycle: step, verification }, result.changed, detail);
1273
+ }
1274
+
1275
+ const verification = verificationStep(verificationResult);
1276
+ if (verificationResult.status === 'pass') {
1277
+ const resolvedVerificationWarning = receipt.warnings.some((warning) => warning.step === 'verification' && warning.status === 'open');
1278
+ const warnings = applyVerificationWarnings(receipt, verificationResult);
1279
+ const hasOpenWarnings = warnings.some((warning) => warning.status === 'open');
1280
+ let updated: TaskFinalizationReceipt;
1281
+ try {
1282
+ updated = updateReceipt(repoRoot, receipt, {
1283
+ lifecycle: 'done', verification: 'done',
1284
+ taskComment: resolvedVerificationWarning ? 'pending' : receipt.taskComment,
1285
+ warningProjection: hasOpenWarnings ? 'pending' : 'done', warnings, lastError: null
1286
+ });
1287
+ } catch (persistCause) {
1288
+ const persistError = errorOf(persistCause, 'FINALIZATION_RECEIPT_WRITE_FAILED', true);
1289
+ persistError.code = 'FINALIZATION_RECEIPT_WRITE_FAILED';
1290
+ return failed(resolved.taskId, persistError, {
1291
+ changed: result.changed,
1292
+ lifecycle: step,
1293
+ verification,
1294
+ completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt)
1295
+ });
1296
+ }
1297
+ return terminalResult(resolved.taskId, updated, { lifecycle: step, verification }, result.changed);
1298
+ }
1299
+
1300
+ const detail = verification.error ?? { code: 'VERIFY_FAILED', message: 'verification failed', retryable: true };
1301
+ const warnings = applyVerificationWarnings(receipt, verificationResult);
1302
+ let updated: TaskFinalizationReceipt;
1303
+ try {
1304
+ updated = updateReceipt(repoRoot, receipt, {
1305
+ lifecycle: 'done', verification: 'pending', taskComment: 'pending', warningProjection: 'pending',
1306
+ warnings, lastError: detail
1307
+ });
1308
+ } catch (persistCause) {
1309
+ const persistError = errorOf(persistCause, 'FINALIZATION_RECEIPT_WRITE_FAILED', true);
1310
+ persistError.code = 'FINALIZATION_RECEIPT_WRITE_FAILED';
1311
+ persistError.message = `${persistError.message}; verification also failed: ${detail.code}: ${detail.message}`;
1312
+ return failed(resolved.taskId, persistError, {
1313
+ changed: result.changed,
1314
+ lifecycle: step,
1315
+ verification: { status: 'blocked', changed: false, error: persistError },
1316
+ completedSteps: completedSteps(receipt), pendingSteps: pendingSteps(receipt), warnings: openWarnings(receipt)
1317
+ });
1318
+ }
1319
+ return terminalResult(resolved.taskId, updated, { lifecycle: step, verification }, result.changed, detail);
1317
1320
  });
1318
1321
  } catch (error) {
1319
1322
  const detail = error instanceof TaskExecutionLockError
@@ -13,7 +13,7 @@ type VerificationEvent =
13
13
  | 'review-analysis.completed' | 'plan.completed' | 'review-plan.completed'
14
14
  | 'code.completed' | 'review-code.completed' | 'manual-validation.completed' | 'validation-run.completed'
15
15
  | 'block-task.completed' | 'cancel-task.completed' | 'commit.completed'
16
- | 'complete-task.preflight' | 'complete-task.hard-preflight' | 'complete-task.prepared' | 'complete-task.completed'
16
+ | 'complete-task.preflight' | 'complete-task.hard-preflight' | 'complete-task.completed'
17
17
  | 'create-pr.completed' | 'create-task.completed'
18
18
  | 'import-codescan.completed' | 'import-dependabot.completed'
19
19
  | 'import-issue.completed' | 'watch-pr.completed'
@@ -68,7 +68,6 @@ const VERIFICATION_CATALOG: Readonly<Record<VerificationEvent, VerificationSpec>
68
68
  'commit.completed': gate('commit', 'active'),
69
69
  'complete-task.preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['required-pr-delivery'] },
70
70
  'complete-task.hard-preflight': { skill: 'complete-task', expectedState: 'active', mode: 'checks', checks: ['required-pr-delivery'] },
71
- 'complete-task.prepared': gate('complete-task', 'active'),
72
71
  'complete-task.completed': gate('complete-task', 'completed'),
73
72
  'create-pr.completed': gate('create-pr', 'active'),
74
73
  'create-task.completed': gate('create-task', 'active'),
package/lib/update.ts CHANGED
@@ -27,7 +27,6 @@ type UpdateConfig = {
27
27
  labels?: Record<string, unknown>;
28
28
  files?: Partial<FileRegistry>;
29
29
  agentClients?: unknown;
30
- customTUIs?: unknown;
31
30
  };
32
31
 
33
32
  type Defaults = {
@@ -189,8 +188,8 @@ async function cmdSync(): Promise<void> {
189
188
  ok('Seed files synced successfully!');
190
189
  console.log('');
191
190
  if (workflowPlan.nextSteps.length === 0) {
192
- console.log(' No Agent Client project integration enabled.');
193
- console.log(` Configure "customTUIs" in ${CONFIG_PATH} if needed.`);
191
+ console.log(' No Agent Client or sandbox custom tool integration enabled.');
192
+ console.log(` Configure a custom tool in "sandbox.tools.definitions" and select it in "sandbox.tools.ids" in ${CONFIG_PATH}.`);
194
193
  console.log('');
195
194
  } else {
196
195
  console.log(' Next step: run the full update in your AI TUI:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
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",